From 5b6badc5ce2c41e697fc7226b8c5052043de34b6 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Sat, 11 Oct 2025 00:14:42 +0100 Subject: [PATCH 01/16] feat: add org support and basic logo --- README.md | 4 + src/commands.ts | 31 +++++-- src/index.ts | 21 ++++- tests/commands.test.ts | 199 +++++++++++++++++++++++++++++++++++------ tests/git.test.ts | 66 ++++++++++---- 5 files changed, 269 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 33dd8ad..acf9dbe 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ +

+ Venfork Logo +

+ # 🔧 Venfork [![CI](https://github.com/cabljac/venfork/actions/workflows/ci.yml/badge.svg)](https://github.com/cabljac/venfork/actions/workflows/ci.yml) diff --git a/src/commands.ts b/src/commands.ts index 346dd80..2538088 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -26,7 +26,8 @@ import { parseRepoName, parseRepoPath } from './utils.js'; */ export async function setupCommand( upstreamUrl?: string, - vendorName?: string + vendorName?: string, + organization?: string ): Promise { p.intro('🔧 Venfork Setup'); @@ -81,6 +82,9 @@ export async function setupCommand( const s = p.spinner(); const username = await getGitHubUsername(); + // Determine the account owner (org or user) + const owner = organization || username; + // Generate unique temp directory in OS temp folder const uniqueId = randomBytes(8).toString('hex'); const tempDir = path.join(os.tmpdir(), `venfork-${uniqueId}`); @@ -112,7 +116,11 @@ export async function setupCommand( // Step 1: Create public fork s.start('Creating public fork of upstream repository'); const upstreamRepoPath = parseRepoPath(config.upstreamUrl); - await $`gh repo fork ${upstreamRepoPath} --clone=false`; + if (organization) { + await $`gh repo fork ${upstreamRepoPath} --clone=false --org ${organization}`; + } else { + await $`gh repo fork ${upstreamRepoPath} --clone=false`; + } s.stop('Public fork created'); // Get the public fork name (same as upstream repo name) @@ -120,7 +128,10 @@ export async function setupCommand( // Step 2: Create private vendor repository s.start('Creating private vendor repository'); - await $`gh repo create ${config.vendorName} --private --clone=false`; + const vendorRepoName = organization + ? `${organization}/${config.vendorName}` + : config.vendorName; + await $`gh repo create ${vendorRepoName} --private --clone=false`; s.stop('Private vendor repository created'); // Step 3: Clone upstream to temp directory @@ -132,12 +143,12 @@ export async function setupCommand( s.start('Pushing to private vendor repository'); await $({ cwd: tempDir, - })`git push --mirror git@github.com:${username}/${config.vendorName}.git`; + })`git push --mirror git@github.com:${owner}/${config.vendorName}.git`; s.stop('Pushed to private vendor repository'); // Step 5: Clone private vendor repo locally s.start('Cloning private vendor repository locally'); - await $`git clone git@github.com:${username}/${config.vendorName}.git`; + await $`git clone git@github.com:${owner}/${config.vendorName}.git`; s.stop('Private vendor repository cloned'); // Step 6: Configure remotes @@ -147,7 +158,7 @@ export async function setupCommand( // Add public fork remote await $({ cwd: repoDir, - })`git remote add public git@github.com:${username}/${publicForkName}.git`; + })`git remote add public git@github.com:${owner}/${publicForkName}.git`; // Add upstream remote (with push disabled) await $({ cwd: repoDir })`git remote add upstream ${config.upstreamUrl}`; @@ -418,9 +429,12 @@ export function showHelp(): void { p.intro('🔧 Venfork - Private Repository Mirrors for Vendor Development'); p.note( - `venfork setup [name] + `venfork setup [name] [--org ] Create private mirror + public fork for vendor workflow + Options: + • --org Create repos under organization instead of user account + Creates: • Private mirror (yourname/project-vendor) - internal work • Public fork (yourname/project) - staging for upstream @@ -444,6 +458,9 @@ venfork stage `# One-time setup venfork setup git@github.com:awesome/project.git +# Or for organization repos: +venfork setup git@github.com:awesome/project.git --org my-company + cd project-vendor # Work privately (juniors can learn here!) diff --git a/src/index.ts b/src/index.ts index 96c6d87..307c19c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,9 +27,26 @@ async function main(): Promise { } switch (command) { - case 'setup': - await setupCommand(args[1], args[2]); + case 'setup': { + // Parse --org flag + const orgIndex = args.indexOf('--org'); + let organization: string | undefined; + let upstreamUrl = args[1]; + let vendorName = args[2]; + + if (orgIndex !== -1) { + organization = args[orgIndex + 1]; + // Remove --org and its value from args + const filteredArgs = args.filter( + (_, i) => i !== orgIndex && i !== orgIndex + 1 + ); + upstreamUrl = filteredArgs[1]; + vendorName = filteredArgs[2]; + } + + await setupCommand(upstreamUrl, vendorName, organization); break; + } case 'sync': await syncCommand(args[1]); break; diff --git a/tests/commands.test.ts b/tests/commands.test.ts index 27a2d73..daf622c 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -10,12 +10,17 @@ interface RmCall { options: { recursive: boolean; force: boolean }; } +type SignalHandler = () => void | Promise; +type MockResponse = + | { exitCode: number; stdout: string; stderr: string } + | (() => Promise); + // Track calls to our mocks const execaCalls: string[] = []; const rmCalls: RmCall[] = []; -let signalHandlers = new Map(); +const signalHandlers = new Map(); let shouldHangOnFork = false; -let mockResponses: Map = new Map(); +const mockResponses: Map = new Map(); // Store originals const originalProcessOn = process.on; @@ -24,14 +29,20 @@ const originalProcessExit = process.exit; // Mock execa BEFORE any imports mock.module('execa', () => ({ + // biome-ignore lint/suspicious/noExplicitAny: Mocking execa's complex overloaded types requires any $: mock((stringsOrOptions: TemplateStringsArray | any, ...values: any[]) => { let command: string; - let options: any = {}; + // biome-ignore lint/suspicious/noExplicitAny: Execa options type is complex + let _options: any = {}; // Handle both $`command` and $({ options })`command` patterns - if (typeof stringsOrOptions === 'object' && !Array.isArray(stringsOrOptions)) { + if ( + typeof stringsOrOptions === 'object' && + !Array.isArray(stringsOrOptions) + ) { // Called with options: $({ cwd: '...' })`command` - options = stringsOrOptions; + _options = stringsOrOptions; + // biome-ignore lint/suspicious/noExplicitAny: Template literal values type return mock((strings: TemplateStringsArray, ...vals: any[]) => { command = String.raw({ raw: strings }, ...vals); execaCalls.push(command); @@ -50,7 +61,9 @@ function getMockExecaResponse(command: string) { // Check if there's a specific mock response set for this test for (const [pattern, response] of mockResponses.entries()) { if (command.includes(pattern)) { - return typeof response === 'function' ? response(command) : Promise.resolve(response); + return typeof response === 'function' + ? response(command) + : Promise.resolve(response); } } @@ -72,18 +85,27 @@ function getMockExecaResponse(command: string) { if (command.includes('git remote -v')) { return Promise.resolve({ exitCode: 0, - stdout: 'origin\tgit@github.com:test/repo.git (fetch)\norigin\tgit@github.com:test/repo.git (push)', + stdout: + 'origin\tgit@github.com:test/repo.git (fetch)\norigin\tgit@github.com:test/repo.git (push)', stderr: '', }); } if (command.includes('git remote get-url')) { - return Promise.resolve({ exitCode: 0, stdout: 'git@github.com:test/repo.git', stderr: '' }); + return Promise.resolve({ + exitCode: 0, + stdout: 'git@github.com:test/repo.git', + stderr: '', + }); } if (command.includes('git remote set-head')) { return Promise.resolve({ exitCode: 0, stdout: '', stderr: '' }); } if (command.includes('git symbolic-ref')) { - return Promise.resolve({ exitCode: 0, stdout: 'refs/remotes/upstream/main', stderr: '' }); + return Promise.resolve({ + exitCode: 0, + stdout: 'refs/remotes/upstream/main', + stderr: '', + }); } // For signal handler tests: make fork command hang to prevent cleanup @@ -98,7 +120,7 @@ function getMockExecaResponse(command: string) { // Mock fs.rm BEFORE any imports mock.module('node:fs/promises', () => ({ - rm: mock((path: string, options: any) => { + rm: mock((path: string, options: { recursive: boolean; force: boolean }) => { rmCalls.push({ path, options }); return Promise.resolve(); }), @@ -124,10 +146,10 @@ mock.module('@clack/prompts', () => ({ // Import commands (will use mocked execa, fs, and prompts) import { setupCommand, - syncCommand, + showHelp, stageCommand, statusCommand, - showHelp, + syncCommand, } from '../src/commands.js'; /** @@ -145,7 +167,7 @@ async function startSetupCommand( // Wait for async operations to complete (checkGhAuth, getGitHubUsername, etc.) // Signal handlers are registered after these complete - await new Promise(resolve => setTimeout(resolve, 50)); + await new Promise((resolve) => setTimeout(resolve, 50)); // Suppress unhandled rejection warnings promise.catch(() => {}); @@ -160,18 +182,21 @@ beforeEach(() => { mockResponses.clear(); // Mock process methods - process.on = ((event: string, handler: Function) => { + process.on = ((event: string, handler: SignalHandler) => { signalHandlers.set(event, handler); return process; + // biome-ignore lint/suspicious/noExplicitAny: Process.on return type is complex }) as any; - process.off = ((event: string, handler: Function) => { + process.off = ((event: string, _handler: SignalHandler) => { signalHandlers.delete(event); return process; + // biome-ignore lint/suspicious/noExplicitAny: Process.off return type is complex }) as any; process.exit = mock(() => { throw new Error('process.exit called'); + // biome-ignore lint/suspicious/noExplicitAny: Process.exit type is complex }) as any; }); @@ -284,7 +309,9 @@ describe('syncCommand', () => { } // Should have called git fetch and git rebase - const fetchCalls = execaCalls.filter((cmd) => cmd.includes('git fetch upstream')); + const fetchCalls = execaCalls.filter((cmd) => + cmd.includes('git fetch upstream') + ); const rebaseCalls = execaCalls.filter((cmd) => cmd.includes('git rebase')); expect(fetchCalls.length).toBeGreaterThanOrEqual(1); @@ -382,10 +409,94 @@ describe('showHelp', () => { }); }); +describe('setupCommand - organization tests', () => { + test('uses --org flag when organization is specified', async () => { + try { + await setupCommand( + 'git@github.com:test/repo.git', + 'test-vendor', + 'my-org' + ); + } catch { + // Expected + } + + // Should call gh repo fork with --org flag + const forkCalls = execaCalls.filter((cmd) => cmd.includes('gh repo fork')); + expect(forkCalls.length).toBeGreaterThan(0); + expect(forkCalls[0]).toContain('--org my-org'); + }); + + test('creates private repo with org/repo format when organization specified', async () => { + try { + await setupCommand( + 'git@github.com:test/repo.git', + 'test-vendor', + 'my-org' + ); + } catch { + // Expected + } + + // Should call gh repo create with org/repo format + const createCalls = execaCalls.filter((cmd) => + cmd.includes('gh repo create') + ); + expect(createCalls.length).toBeGreaterThan(0); + expect(createCalls[0]).toContain('my-org/test-vendor'); + }); + + test('uses organization in git URLs when specified', async () => { + try { + await setupCommand( + 'git@github.com:test/repo.git', + 'test-vendor', + 'my-org' + ); + } catch { + // Expected + } + + // Should use org in clone and remote URLs + const cloneCalls = execaCalls.filter((cmd) => cmd.includes('git clone')); + const remoteCalls = execaCalls.filter((cmd) => + cmd.includes('git remote add') + ); + + expect(cloneCalls.some((cmd) => cmd.includes('my-org/test-vendor'))).toBe( + true + ); + expect(remoteCalls.some((cmd) => cmd.includes('my-org/'))).toBe(true); + }); + + test('uses username when no organization specified', async () => { + try { + await setupCommand('git@github.com:test/repo.git', 'test-vendor'); + } catch { + // Expected + } + + // Should NOT include --org flag + const forkCalls = execaCalls.filter((cmd) => cmd.includes('gh repo fork')); + expect(forkCalls.length).toBeGreaterThan(0); + expect(forkCalls[0]).not.toContain('--org'); + + // Should use testuser (from mock) in URLs + const cloneCalls = execaCalls.filter((cmd) => cmd.includes('git clone')); + expect(cloneCalls.some((cmd) => cmd.includes('testuser/test-vendor'))).toBe( + true + ); + }); +}); + describe('setupCommand - error paths', () => { test('throws AuthenticationError when not authenticated', async () => { // Mock checkGhAuth to return false - mockResponses.set('gh auth status', { exitCode: 1, stdout: '', stderr: 'not authenticated' }); + mockResponses.set('gh auth status', { + exitCode: 1, + stdout: '', + stderr: 'not authenticated', + }); try { await setupCommand('git@github.com:test/repo.git', 'test-vendor'); @@ -397,7 +508,9 @@ describe('setupCommand - error paths', () => { test('handles error in catch block', async () => { // Make fork command fail instead of hanging - mockResponses.set('gh repo fork', () => Promise.reject(new Error('Fork failed'))); + mockResponses.set('gh repo fork', () => + Promise.reject(new Error('Fork failed')) + ); try { await setupCommand('git@github.com:test/repo.git', 'test-vendor'); @@ -412,7 +525,11 @@ describe('setupCommand - error paths', () => { describe('syncCommand - error paths', () => { test('throws error when current branch cannot be determined', async () => { - mockResponses.set('git branch --show-current', { exitCode: 0, stdout: '', stderr: '' }); + mockResponses.set('git branch --show-current', { + exitCode: 0, + stdout: '', + stderr: '', + }); try { await syncCommand('main'); @@ -423,7 +540,9 @@ describe('syncCommand - error paths', () => { }); test('handles rebase conflicts', async () => { - mockResponses.set('git rebase', () => Promise.reject(new Error('Rebase conflict'))); + mockResponses.set('git rebase', () => + Promise.reject(new Error('Rebase conflict')) + ); try { await syncCommand('main'); @@ -435,7 +554,9 @@ describe('syncCommand - error paths', () => { }); test('handles general errors', async () => { - mockResponses.set('git fetch', () => Promise.reject(new Error('Fetch failed'))); + mockResponses.set('git fetch', () => + Promise.reject(new Error('Fetch failed')) + ); try { await syncCommand('main'); @@ -449,7 +570,11 @@ describe('syncCommand - error paths', () => { describe('stageCommand - error paths', () => { test('throws AuthenticationError when not authenticated', async () => { - mockResponses.set('gh auth status', { exitCode: 1, stdout: '', stderr: 'not authenticated' }); + mockResponses.set('gh auth status', { + exitCode: 1, + stdout: '', + stderr: 'not authenticated', + }); try { await stageCommand('feature-branch'); @@ -460,7 +585,11 @@ describe('stageCommand - error paths', () => { }); test('throws BranchNotFoundError when branch does not exist', async () => { - mockResponses.set('git rev-parse --verify', { exitCode: 1, stdout: '', stderr: 'not found' }); + mockResponses.set('git rev-parse --verify', { + exitCode: 1, + stdout: '', + stderr: 'not found', + }); try { await stageCommand('nonexistent-branch'); @@ -472,7 +601,11 @@ describe('stageCommand - error paths', () => { }); test('throws RemoteNotFoundError when public remote missing', async () => { - mockResponses.set('git remote get-url public', { exitCode: 1, stdout: '', stderr: 'not found' }); + mockResponses.set('git remote get-url public', { + exitCode: 1, + stdout: '', + stderr: 'not found', + }); try { await stageCommand('feature-branch'); @@ -486,7 +619,11 @@ describe('stageCommand - error paths', () => { describe('statusCommand - error paths', () => { test('throws NotInRepositoryError when not in git repo', async () => { - mockResponses.set('git rev-parse --git-dir', { exitCode: 128, stdout: '', stderr: 'not a git repository' }); + mockResponses.set('git rev-parse --git-dir', { + exitCode: 128, + stdout: '', + stderr: 'not a git repository', + }); try { await statusCommand(); @@ -506,12 +643,16 @@ describe('statusCommand - error paths', () => { } // Command should run successfully - expect(execaCalls.some(cmd => cmd.includes('git remote -v'))).toBe(true); + expect(execaCalls.some((cmd) => cmd.includes('git remote -v'))).toBe(true); }); test('shows incomplete setup message when missing remotes', async () => { // Mock hasRemote to return false for public - mockResponses.set('git remote get-url public', { exitCode: 1, stdout: '', stderr: 'not found' }); + mockResponses.set('git remote get-url public', { + exitCode: 1, + stdout: '', + stderr: 'not found', + }); try { await statusCommand(); @@ -520,6 +661,8 @@ describe('statusCommand - error paths', () => { } // Should check for remotes - expect(execaCalls.some(cmd => cmd.includes('git remote get-url'))).toBe(true); + expect(execaCalls.some((cmd) => cmd.includes('git remote get-url'))).toBe( + true + ); }); }); diff --git a/tests/git.test.ts b/tests/git.test.ts index e3649ad..9c19b8e 100644 --- a/tests/git.test.ts +++ b/tests/git.test.ts @@ -5,22 +5,32 @@ import { beforeEach, describe, expect, mock, test } from 'bun:test'; * These tests verify the actual logic in git.ts functions */ +type ExecaOptions = Record; +type MockResponse = + | { exitCode: number; stdout: string; stderr: string } + | (() => Promise); + // Track execa calls for verification -const execaCalls: Array<{ command: string; options?: any }> = []; +const execaCalls: Array<{ command: string; options?: ExecaOptions }> = []; // Control mock behavior per test -let mockResponses: Map = new Map(); +const mockResponses: Map = new Map(); // Mock execa BEFORE importing git.ts mock.module('execa', () => ({ + // biome-ignore lint/suspicious/noExplicitAny: Mocking execa's complex overloaded types requires any $: mock((stringsOrOptions: TemplateStringsArray | any, ...values: any[]) => { let command: string; - let options: any = {}; + let options: ExecaOptions = {}; // Handle both $`command` and $({ options })`command` patterns - if (typeof stringsOrOptions === 'object' && !Array.isArray(stringsOrOptions)) { + if ( + typeof stringsOrOptions === 'object' && + !Array.isArray(stringsOrOptions) + ) { // Called with options: $({ cwd: '...' })`command` options = stringsOrOptions; + // biome-ignore lint/suspicious/noExplicitAny: Template literal values type return mock((strings: TemplateStringsArray, ...vals: any[]) => { command = String.raw({ raw: strings }, ...vals); execaCalls.push({ command, options }); @@ -35,11 +45,13 @@ mock.module('execa', () => ({ }), })); -function getMockResponse(command: string, options: any = {}) { +function getMockResponse(command: string, _options: ExecaOptions = {}) { // Check if there's a specific mock response set for this test for (const [pattern, response] of mockResponses.entries()) { if (command.includes(pattern)) { - return typeof response === 'function' ? response(command) : Promise.resolve(response); + return typeof response === 'function' + ? response(command) + : Promise.resolve(response); } } @@ -103,7 +115,11 @@ beforeEach(() => { describe('checkGhAuth', () => { test('returns true when gh auth status succeeds', async () => { - mockResponses.set('gh auth status', { exitCode: 0, stdout: '', stderr: '' }); + mockResponses.set('gh auth status', { + exitCode: 0, + stdout: '', + stderr: '', + }); const result = await checkGhAuth(); @@ -125,7 +141,9 @@ describe('checkGhAuth', () => { }); test('returns false when command throws error', async () => { - mockResponses.set('gh auth status', () => Promise.reject(new Error('command failed'))); + mockResponses.set('gh auth status', () => + Promise.reject(new Error('command failed')) + ); const result = await checkGhAuth(); @@ -148,7 +166,9 @@ describe('getCurrentBranch', () => { }); test('returns empty string on error', async () => { - mockResponses.set('git branch', () => Promise.reject(new Error('not a git repo'))); + mockResponses.set('git branch', () => + Promise.reject(new Error('not a git repo')) + ); const result = await getCurrentBranch(); @@ -195,7 +215,9 @@ describe('getGitHubUsername', () => { }); test('returns empty string on error', async () => { - mockResponses.set('gh api', () => Promise.reject(new Error('not authenticated'))); + mockResponses.set('gh api', () => + Promise.reject(new Error('not authenticated')) + ); const result = await getGitHubUsername(); @@ -205,7 +227,11 @@ describe('getGitHubUsername', () => { describe('isGitRepository', () => { test('returns true when in git repository', async () => { - mockResponses.set('git rev-parse', { exitCode: 0, stdout: '.git', stderr: '' }); + mockResponses.set('git rev-parse', { + exitCode: 0, + stdout: '.git', + stderr: '', + }); const result = await isGitRepository(); @@ -226,7 +252,9 @@ describe('isGitRepository', () => { }); test('returns false on command error', async () => { - mockResponses.set('git rev-parse', () => Promise.reject(new Error('command failed'))); + mockResponses.set('git rev-parse', () => + Promise.reject(new Error('command failed')) + ); const result = await isGitRepository(); @@ -328,7 +356,9 @@ describe('getRemotes', () => { }); test('returns empty object on command error', async () => { - mockResponses.set('git remote', () => Promise.reject(new Error('command failed'))); + mockResponses.set('git remote', () => + Promise.reject(new Error('command failed')) + ); const result = await getRemotes(); @@ -393,7 +423,11 @@ describe('hasRemote', () => { }); test('passes remote name to command', async () => { - mockResponses.set('git remote get-url', { exitCode: 0, stdout: 'url', stderr: '' }); + mockResponses.set('git remote get-url', { + exitCode: 0, + stdout: 'url', + stderr: '', + }); await hasRemote('my-custom-remote'); @@ -424,7 +458,9 @@ describe('getDefaultBranch', () => { await getDefaultBranch('origin'); expect(execaCalls[0].command).toContain('git remote set-head origin -a'); - expect(execaCalls[1].command).toContain('git symbolic-ref refs/remotes/origin/HEAD'); + expect(execaCalls[1].command).toContain( + 'git symbolic-ref refs/remotes/origin/HEAD' + ); }); test('uses upstream as default remote', async () => { From df50d53432b1072a5ebad96d8f9ca275f36ede7f Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Sat, 11 Oct 2025 00:33:56 +0100 Subject: [PATCH 02/16] chore: refine README language and icon --- README.md | 64 ++++++++++++++++++++++++++++++++++--------------- assets/logo.svg | 6 +++++ package.json | 1 + 3 files changed, 52 insertions(+), 19 deletions(-) create mode 100644 assets/logo.svg diff --git a/README.md b/README.md index acf9dbe..ec339ba 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- Venfork Logo + Venfork Logo

# 🔧 Venfork @@ -13,19 +13,21 @@ Create and manage private mirrors of public GitHub repositories for vendor devel Venfork helps contractors and vendors who need to work on private forks of public repositories. It creates a **three-repository workflow**: -1. **Private Mirror** (`yourname/project-vendor`) - Where your team works internally -2. **Public Fork** (`yourname/project`) - Staging area for contributions to upstream +1. **Private Mirror** (`yourname/project-vendor` or `org/project-vendor`) - Where your team works internally +2. **Public Fork** (`yourname/project` or `org/project`) - Staging area for contributions to upstream 3. **Upstream** (`original/project`) - The original repository +> **Note:** Repos can be created under your personal account or under an organization using the `--org` flag. + ### Why Three Repositories? **The Key Insight:** -> "Because the private fork is not attached to the public repo, our juniors can work on it and learn there without being seen by our client" +> "The private mirror is completely disconnected from the public fork, allowing teams to experiment freely before presenting work to the client" The private mirror is: - ✅ Completely disconnected from the public fork -- ✅ Safe for junior devs to learn, make mistakes, iterate -- ✅ All internal PRs, reviews, experiments stay private +- ✅ Safe space to experiment, iterate, and refine work +- ✅ All internal PRs, reviews, and experiments stay private - ✅ Only visible to your team When you run `venfork stage`, your work becomes visible on the public fork and ready for PR to upstream. @@ -61,14 +63,17 @@ npx venfork setup ## Quick Start ```bash -# 1. One-time setup +# 1. One-time setup (personal account) venfork setup git@github.com:awesome/project.git +# Or for organization repos +venfork setup git@github.com:awesome/project.git --org my-company + cd project-vendor # 2. Work privately git checkout -b feature/new-thing -# ... make changes, learn, iterate ... +# ... experiment, iterate, refine ... git push origin feature/new-thing # Still private! Create internal PR for team review @@ -80,13 +85,13 @@ venfork stage feature/new-thing ## Commands -### `venfork setup [name]` +### `venfork setup [name] [--org ]` Creates the complete vendor workflow setup: **What it creates:** -- **Private mirror** (`yourname/project-vendor`) - For internal work -- **Public fork** (`yourname/project`) - For staging to upstream +- **Private mirror** (`yourname/project-vendor` or `org/project-vendor`) - For internal work +- **Public fork** (`yourname/project` or `org/project`) - For staging to upstream - **Local clone** with three remotes configured: - `origin` → private mirror (default push/pull) - `public` → public fork (for staging) @@ -95,14 +100,23 @@ Creates the complete vendor workflow setup: **Arguments:** - `upstream-url` - GitHub repository URL (SSH or HTTPS) - `name` - (Optional) Name for private vendor repo (default: `{repo}-vendor`) +- `--org ` - (Optional) Create repos under organization instead of personal account **Examples:** ```bash +# Personal account (default) venfork setup git@github.com:vercel/next.js.git -# Creates: next.js-vendor (private), next.js (public fork) +# Creates: yourname/next.js-vendor (private), yourname/next.js (public fork) venfork setup https://github.com/vuejs/vue.git vue-internal -# Creates: vue-internal (private), vue (public fork) +# Creates: yourname/vue-internal (private), yourname/vue (public fork) + +# Organization account +venfork setup git@github.com:client/awesome-project.git --org acme-corp +# Creates: acme-corp/awesome-project-vendor (private), acme-corp/awesome-project (public fork) + +venfork setup git@github.com:client/project.git internal-mirror --org my-company +# Creates: my-company/internal-mirror (private), my-company/project (public fork) ``` ### `venfork sync [branch]` @@ -170,9 +184,12 @@ venfork stage bugfix/issue-123 ### Initial Setup ```bash -# Clone and configure the repos +# Clone and configure the repos (personal account) venfork setup git@github.com:client/awesome-project.git +# Or for organization +venfork setup git@github.com:client/awesome-project.git --org acme-corp + # Navigate to private mirror cd awesome-project-vendor @@ -181,9 +198,15 @@ venfork status # Or verify remotes manually git remote -v +# With personal account: # origin git@github.com:you/awesome-project-vendor.git (private) # public git@github.com:you/awesome-project.git (public fork) # upstream git@github.com:client/awesome-project.git (read-only) + +# With organization: +# origin git@github.com:acme-corp/awesome-project-vendor.git (private) +# public git@github.com:acme-corp/awesome-project.git (public fork) +# upstream git@github.com:client/awesome-project.git (read-only) ``` ### Daily Development @@ -210,7 +233,7 @@ git push origin feature/user-auth ```bash # Team reviews PR in private repo -# Junior devs iterate, learn, make mistakes +# Experiment, iterate, refine approach # All feedback and changes stay private # Once approved internally, merge to main @@ -245,7 +268,7 @@ venfork stage feature/user-auth │ fork ▼ ┌─────────────────────────────────────────────┐ -│ Public Fork (you/project) │ +│ Public Fork (you/project or org/project) │ │ • Visible to everyone │ │ • Staging area for PRs │ │ • Only pushed to via `venfork stage` │ @@ -255,8 +278,9 @@ venfork stage feature/user-auth ▼ ┌─────────────────────────────────────────────┐ │ Private Mirror (you/project-vendor) │ +│ (or org/project-vendor) │ │ • Only visible to your team │ -│ • Where juniors learn & iterate │ +│ • Safe space to experiment & iterate │ │ • Internal PRs and reviews │ │ • Your daily work happens here │ └─────────────────────────────────────────────┘ @@ -270,10 +294,12 @@ After `venfork setup`, your local repository has three remotes: | Remote | URL | Purpose | |--------|-----|---------| -| `origin` | `you/project-vendor` | Private work (default) | -| `public` | `you/project` | Stage for upstream | +| `origin` | `you/project-vendor` (or `org/project-vendor`) | Private work (default) | +| `public` | `you/project` (or `org/project`) | Stage for upstream | | `upstream` | `original/project` | Sync with latest | +**Note:** When using `--org`, all repos are created under the specified organization. + ### Default Behavior - `git push` → Pushes to `origin` (private mirror) diff --git a/assets/logo.svg b/assets/logo.svg new file mode 100644 index 0000000..4b8c252 --- /dev/null +++ b/assets/logo.svg @@ -0,0 +1,6 @@ + + + forking-line + + + \ No newline at end of file diff --git a/package.json b/package.json index 08951c9..3ef84ff 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ }, "files": [ "dist", + "assets", "README.md", "LICENSE" ], From b430d1c690256a4a3149033393c46cb2ef9a6116 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Sat, 11 Oct 2025 00:35:55 +0100 Subject: [PATCH 03/16] chore: update lockfile --- bun.lock | 206 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 204 insertions(+), 2 deletions(-) diff --git a/bun.lock b/bun.lock index 642aeb1..da1ee00 100644 --- a/bun.lock +++ b/bun.lock @@ -9,12 +9,16 @@ }, "devDependencies": { "@biomejs/biome": "latest", + "@changesets/changelog-github": "^0.5.0", + "@changesets/cli": "^2.27.1", "@types/bun": "latest", "typescript": "^5.7.3", }, }, }, "packages": { + "@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="], + "@biomejs/biome": ["@biomejs/biome@2.2.5", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.2.5", "@biomejs/cli-darwin-x64": "2.2.5", "@biomejs/cli-linux-arm64": "2.2.5", "@biomejs/cli-linux-arm64-musl": "2.2.5", "@biomejs/cli-linux-x64": "2.2.5", "@biomejs/cli-linux-x64-musl": "2.2.5", "@biomejs/cli-win32-arm64": "2.2.5", "@biomejs/cli-win32-x64": "2.2.5" }, "bin": { "biome": "bin/biome" } }, "sha512-zcIi+163Rc3HtyHbEO7CjeHq8DjQRs40HsGbW6vx2WI0tg8mYQOPouhvHSyEnCBAorfYNnKdR64/IxO7xQ5faw=="], "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.2.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MYT+nZ38wEIWVcL5xLyOhYQQ7nlWD0b/4mgATW2c8dvq7R4OQjt/XGXFkXrmtWmQofaIM14L7V8qIz/M+bx5QQ=="], @@ -33,52 +37,220 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.5", "", { "os": "win32", "cpu": "x64" }, "sha512-F/jhuXCssPFAuciMhHKk00xnCAxJRS/pUzVfXYmOMUp//XW7mO6QeCjsjvnm8L4AO/dG2VOB0O+fJPiJ2uXtIw=="], + "@changesets/apply-release-plan": ["@changesets/apply-release-plan@7.0.13", "", { "dependencies": { "@changesets/config": "^3.1.1", "@changesets/get-version-range-type": "^0.4.0", "@changesets/git": "^3.0.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "detect-indent": "^6.0.0", "fs-extra": "^7.0.1", "lodash.startcase": "^4.4.0", "outdent": "^0.5.0", "prettier": "^2.7.1", "resolve-from": "^5.0.0", "semver": "^7.5.3" } }, "sha512-BIW7bofD2yAWoE8H4V40FikC+1nNFEKBisMECccS16W1rt6qqhNTBDmIw5HaqmMgtLNz9e7oiALiEUuKrQ4oHg=="], + + "@changesets/assemble-release-plan": ["@changesets/assemble-release-plan@6.0.9", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.3", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "semver": "^7.5.3" } }, "sha512-tPgeeqCHIwNo8sypKlS3gOPmsS3wP0zHt67JDuL20P4QcXiw/O4Hl7oXiuLnP9yg+rXLQ2sScdV1Kkzde61iSQ=="], + + "@changesets/changelog-git": ["@changesets/changelog-git@0.2.1", "", { "dependencies": { "@changesets/types": "^6.1.0" } }, "sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q=="], + + "@changesets/changelog-github": ["@changesets/changelog-github@0.5.1", "", { "dependencies": { "@changesets/get-github-info": "^0.6.0", "@changesets/types": "^6.1.0", "dotenv": "^8.1.0" } }, "sha512-BVuHtF+hrhUScSoHnJwTELB4/INQxVFc+P/Qdt20BLiBFIHFJDDUaGsZw+8fQeJTRP5hJZrzpt3oZWh0G19rAQ=="], + + "@changesets/cli": ["@changesets/cli@2.29.7", "", { "dependencies": { "@changesets/apply-release-plan": "^7.0.13", "@changesets/assemble-release-plan": "^6.0.9", "@changesets/changelog-git": "^0.2.1", "@changesets/config": "^3.1.1", "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.3", "@changesets/get-release-plan": "^4.0.13", "@changesets/git": "^3.0.4", "@changesets/logger": "^0.1.1", "@changesets/pre": "^2.0.2", "@changesets/read": "^0.6.5", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@changesets/write": "^0.4.0", "@inquirer/external-editor": "^1.0.0", "@manypkg/get-packages": "^1.1.3", "ansi-colors": "^4.1.3", "ci-info": "^3.7.0", "enquirer": "^2.4.1", "fs-extra": "^7.0.1", "mri": "^1.2.0", "p-limit": "^2.2.0", "package-manager-detector": "^0.2.0", "picocolors": "^1.1.0", "resolve-from": "^5.0.0", "semver": "^7.5.3", "spawndamnit": "^3.0.1", "term-size": "^2.1.0" }, "bin": { "changeset": "bin.js" } }, "sha512-R7RqWoaksyyKXbKXBTbT4REdy22yH81mcFK6sWtqSanxUCbUi9Uf+6aqxZtDQouIqPdem2W56CdxXgsxdq7FLQ=="], + + "@changesets/config": ["@changesets/config@3.1.1", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.3", "@changesets/logger": "^0.1.1", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "fs-extra": "^7.0.1", "micromatch": "^4.0.8" } }, "sha512-bd+3Ap2TKXxljCggI0mKPfzCQKeV/TU4yO2h2C6vAihIo8tzseAn2e7klSuiyYYXvgu53zMN1OeYMIQkaQoWnA=="], + + "@changesets/errors": ["@changesets/errors@0.2.0", "", { "dependencies": { "extendable-error": "^0.1.5" } }, "sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow=="], + + "@changesets/get-dependents-graph": ["@changesets/get-dependents-graph@2.1.3", "", { "dependencies": { "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "picocolors": "^1.1.0", "semver": "^7.5.3" } }, "sha512-gphr+v0mv2I3Oxt19VdWRRUxq3sseyUpX9DaHpTUmLj92Y10AGy+XOtV+kbM6L/fDcpx7/ISDFK6T8A/P3lOdQ=="], + + "@changesets/get-github-info": ["@changesets/get-github-info@0.6.0", "", { "dependencies": { "dataloader": "^1.4.0", "node-fetch": "^2.5.0" } }, "sha512-v/TSnFVXI8vzX9/w3DU2Ol+UlTZcu3m0kXTjTT4KlAdwSvwutcByYwyYn9hwerPWfPkT2JfpoX0KgvCEi8Q/SA=="], + + "@changesets/get-release-plan": ["@changesets/get-release-plan@4.0.13", "", { "dependencies": { "@changesets/assemble-release-plan": "^6.0.9", "@changesets/config": "^3.1.1", "@changesets/pre": "^2.0.2", "@changesets/read": "^0.6.5", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3" } }, "sha512-DWG1pus72FcNeXkM12tx+xtExyH/c9I1z+2aXlObH3i9YA7+WZEVaiHzHl03thpvAgWTRaH64MpfHxozfF7Dvg=="], + + "@changesets/get-version-range-type": ["@changesets/get-version-range-type@0.4.0", "", {}, "sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ=="], + + "@changesets/git": ["@changesets/git@3.0.4", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@manypkg/get-packages": "^1.1.3", "is-subdir": "^1.1.1", "micromatch": "^4.0.8", "spawndamnit": "^3.0.1" } }, "sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw=="], + + "@changesets/logger": ["@changesets/logger@0.1.1", "", { "dependencies": { "picocolors": "^1.1.0" } }, "sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg=="], + + "@changesets/parse": ["@changesets/parse@0.4.1", "", { "dependencies": { "@changesets/types": "^6.1.0", "js-yaml": "^3.13.1" } }, "sha512-iwksMs5Bf/wUItfcg+OXrEpravm5rEd9Bf4oyIPL4kVTmJQ7PNDSd6MDYkpSJR1pn7tz/k8Zf2DhTCqX08Ou+Q=="], + + "@changesets/pre": ["@changesets/pre@2.0.2", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "fs-extra": "^7.0.1" } }, "sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug=="], + + "@changesets/read": ["@changesets/read@0.6.5", "", { "dependencies": { "@changesets/git": "^3.0.4", "@changesets/logger": "^0.1.1", "@changesets/parse": "^0.4.1", "@changesets/types": "^6.1.0", "fs-extra": "^7.0.1", "p-filter": "^2.1.0", "picocolors": "^1.1.0" } }, "sha512-UPzNGhsSjHD3Veb0xO/MwvasGe8eMyNrR/sT9gR8Q3DhOQZirgKhhXv/8hVsI0QpPjR004Z9iFxoJU6in3uGMg=="], + + "@changesets/should-skip-package": ["@changesets/should-skip-package@0.1.2", "", { "dependencies": { "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3" } }, "sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw=="], + + "@changesets/types": ["@changesets/types@6.1.0", "", {}, "sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA=="], + + "@changesets/write": ["@changesets/write@0.4.0", "", { "dependencies": { "@changesets/types": "^6.1.0", "fs-extra": "^7.0.1", "human-id": "^4.1.1", "prettier": "^2.7.1" } }, "sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q=="], + "@clack/core": ["@clack/core@0.5.0", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow=="], "@clack/prompts": ["@clack/prompts@0.11.0", "", { "dependencies": { "@clack/core": "0.5.0", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw=="], + "@inquirer/external-editor": ["@inquirer/external-editor@1.0.2", "", { "dependencies": { "chardet": "^2.1.0", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-yy9cOoBnx58TlsPrIxauKIFQTiyH+0MK4e97y4sV9ERbI+zDxw7i2hxHLCIEGIE/8PPvDxGhgzIOTSOWcs6/MQ=="], + + "@manypkg/find-root": ["@manypkg/find-root@1.1.0", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@types/node": "^12.7.1", "find-up": "^4.1.0", "fs-extra": "^8.1.0" } }, "sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA=="], + + "@manypkg/get-packages": ["@manypkg/get-packages@1.1.3", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@changesets/types": "^4.0.1", "@manypkg/find-root": "^1.1.0", "fs-extra": "^8.1.0", "globby": "^11.0.0", "read-yaml-file": "^1.1.0" } }, "sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A=="], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], - "@types/bun": ["@types/bun@1.2.23", "", { "dependencies": { "bun-types": "1.2.23" } }, "sha512-le8ueOY5b6VKYf19xT3McVbXqLqmxzPXHsQT/q9JHgikJ2X22wyTW3g3ohz2ZMnp7dod6aduIiq8A14Xyimm0A=="], + "@types/bun": ["@types/bun@1.3.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="], "@types/node": ["@types/node@24.7.1", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-CmyhGZanP88uuC5GpWU9q+fI61j2SkhO3UGMUdfYRE6Bcy0ccyzn1Rqj9YAB/ZY4kOXmNf0ocah5GtphmLMP6Q=="], "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="], - "bun-types": ["bun-types@1.2.23", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-R9f0hKAZXgFU3mlrA0YpE/fiDvwV0FT9rORApt2aQVWSuJDzZOyB5QLc0N/4HF57CS8IXJ6+L5E4W1bW6NS2Aw=="], + "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + + "array-union": ["array-union@2.1.0", "", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="], + + "better-path-resolve": ["better-path-resolve@1.0.0", "", { "dependencies": { "is-windows": "^1.0.0" } }, "sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="], + + "chardet": ["chardet@2.1.0", "", {}, "sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA=="], + + "ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + "dataloader": ["dataloader@1.4.0", "", {}, "sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw=="], + + "detect-indent": ["detect-indent@6.1.0", "", {}, "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA=="], + + "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], + + "dotenv": ["dotenv@8.6.0", "", {}, "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g=="], + + "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + "execa": ["execa@9.6.0", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw=="], + "extendable-error": ["extendable-error@0.1.7", "", {}, "sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg=="], + + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], + "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + + "fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], + "get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], + "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "globby": ["globby@11.1.0", "", { "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", "fast-glob": "^3.2.9", "ignore": "^5.2.0", "merge2": "^1.4.1", "slash": "^3.0.0" } }, "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "human-id": ["human-id@4.1.2", "", { "bin": { "human-id": "dist/cli.js" } }, "sha512-v/J+4Z/1eIJovEBdlV5TYj1IR+ZiohcYGRY+qN/oC9dAfKzVT023N/Bgw37hrKCoVRBvk3bqyzpr2PP5YeTMSg=="], + "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], + "iconv-lite": ["iconv-lite@0.7.0", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], + "is-subdir": ["is-subdir@1.2.0", "", { "dependencies": { "better-path-resolve": "1.0.0" } }, "sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw=="], + "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], + "is-windows": ["is-windows@1.0.2", "", {}, "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="], + + "jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], + + "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + + "lodash.startcase": ["lodash.startcase@4.4.0", "", {}, "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg=="], + + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], + + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], + "outdent": ["outdent@0.5.0", "", {}, "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q=="], + + "p-filter": ["p-filter@2.1.0", "", { "dependencies": { "p-map": "^2.0.0" } }, "sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw=="], + + "p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + + "p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + + "p-map": ["p-map@2.1.0", "", {}, "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw=="], + + "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], + + "package-manager-detector": ["package-manager-detector@0.2.11", "", { "dependencies": { "quansync": "^0.2.7" } }, "sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ=="], + "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "pify": ["pify@4.0.1", "", {}, "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="], + + "prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], + "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], + "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "read-yaml-file": ["read-yaml-file@1.1.0", "", { "dependencies": { "graceful-fs": "^4.1.5", "js-yaml": "^3.6.1", "pify": "^4.0.1", "strip-bom": "^3.0.0" } }, "sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA=="], + + "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], @@ -87,18 +259,48 @@ "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + + "spawndamnit": ["spawndamnit@3.0.1", "", { "dependencies": { "cross-spawn": "^7.0.5", "signal-exit": "^4.0.1" } }, "sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg=="], + + "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], + "strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], + "term-size": ["term-size@2.2.1", "", {}, "sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], + "universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], + + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], + "@manypkg/find-root/@types/node": ["@types/node@12.20.55", "", {}, "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="], + + "@manypkg/find-root/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], + + "@manypkg/get-packages/@changesets/types": ["@changesets/types@4.1.0", "", {}, "sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw=="], + + "@manypkg/get-packages/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], + "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], } } From 4cb07900122bada1328a4203e5ecce183a79f2db Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Sat, 11 Oct 2025 00:50:25 +0100 Subject: [PATCH 04/16] refactor: change how sync works --- README.md | 36 ++++++++------- src/commands.ts | 88 ++++++++++++++++++++++++------------ tests/commands.test.ts | 100 ++++++++++++++++++++++++++++++++--------- 3 files changed, 159 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index ec339ba..30cd586 100644 --- a/README.md +++ b/README.md @@ -121,21 +121,27 @@ venfork setup git@github.com:client/project.git internal-mirror --org my-company ### `venfork sync [branch]` -Fetch from upstream and rebase current branch to stay up-to-date. +Update the default branches of your private mirror and public fork to match upstream. **Arguments:** -- `branch` - (Optional) Upstream branch to sync with (default: `main`) +- `branch` - (Optional) Upstream branch to sync (default: auto-detected, usually `main` or `master`) **Examples:** ```bash -venfork sync # Sync with upstream/main -venfork sync develop # Sync with upstream/develop +venfork sync # Sync default branches with upstream +venfork sync develop # Sync develop branch with upstream/develop ``` **What it does:** -1. Fetches latest changes from upstream -2. Rebases your current branch on upstream -3. Handles conflicts gracefully with instructions +1. Fetches latest changes from all remotes (upstream, origin, public) +2. Checks for divergent commits (warns if found to prevent data loss) +3. Force pushes upstream's default branch to origin and public +4. **Does not affect your current working branch or feature branches** + +**Important:** +- This keeps your default branches (main/master) in sync with upstream +- Your current work on feature branches is completely unaffected +- If divergent commits are detected, sync will abort to prevent data loss ### `venfork status` @@ -212,7 +218,7 @@ git remote -v ### Daily Development ```bash -# Sync with upstream before starting +# Sync default branches with upstream (optional, keeps main up-to-date) venfork sync # Create feature branch @@ -304,7 +310,7 @@ After `venfork setup`, your local repository has three remotes: - `git push` → Pushes to `origin` (private mirror) - `git pull` → Pulls from `origin` (private mirror) -- `venfork sync` → Fetches from `upstream` +- `venfork sync` → Updates default branches of `origin` and `public` to match `upstream` - `venfork stage` → Pushes to `public` ## Troubleshooting @@ -330,13 +336,13 @@ This means `venfork setup` wasn't run or didn't complete successfully. - Run `venfork status` to see which remotes are missing - Re-run `venfork setup` if needed -### Rebase Conflicts +### Divergent Commits Warning -When `venfork sync` encounters conflicts: -1. Open the conflicted files and resolve markers -2. Stage the resolved files: `git add ` -3. Continue: `git rebase --continue` -4. Or abort: `git rebase --abort` +If `venfork sync` detects commits on your default branch that aren't in upstream: +1. This suggests work was committed directly to main/master (not recommended) +2. Sync will abort to prevent losing these commits +3. To preserve: manually rebase or cherry-pick them to a feature branch +4. To force sync anyway: `git push origin upstream/main:main -f` (loses commits) ### Branch Already Exists on Public Fork diff --git a/src/commands.ts b/src/commands.ts index 2538088..02dfc88 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -201,7 +201,7 @@ Upstream: ${config.upstreamUrl} (read-only)`, } /** - * Sync command: Fetch from upstream and rebase current branch + * Sync command: Update default branches of origin and public to match upstream */ export async function syncCommand(targetBranch?: string): Promise { p.intro('🔄 Venfork Sync'); @@ -209,44 +209,76 @@ export async function syncCommand(targetBranch?: string): Promise { const s = p.spinner(); try { - // Get current branch - const currentBranch = await getCurrentBranch(); - if (!currentBranch) { - throw new Error('Could not determine current branch'); - } - // Step 1: Fetch from upstream s.start('Fetching from upstream'); await $`git fetch upstream`; - s.stop('Fetched from upstream'); + await $`git fetch origin`; + await $`git fetch public`; + s.stop('Fetched from all remotes'); // Step 2: Detect default branch if not specified - const branch = targetBranch || (await getDefaultBranch('upstream')); + const defaultBranch = targetBranch || (await getDefaultBranch('upstream')); - // Step 3: Rebase current branch on upstream - s.start(`Rebasing ${currentBranch} on upstream/${branch}`); - try { - await $`git rebase upstream/${branch}`; - s.stop('Rebase successful'); + // Step 3: Check for divergence + s.start('Checking for divergent commits'); - p.outro( - `✨ Sync complete! ${currentBranch} is now up to date with upstream/${branch}` - ); - } catch (_rebaseError) { - s.stop('Rebase conflicts detected'); + const checkDivergence = async (remote: string): Promise => { + try { + const result = + await $`git rev-list --count upstream/${defaultBranch}..${remote}/${defaultBranch}`; + return Number.parseInt(result.stdout.trim(), 10); + } catch { + // Remote branch might not exist yet (first sync) + return 0; + } + }; + + const originDivergence = await checkDivergence('origin'); + const publicDivergence = await checkDivergence('public'); + + s.stop('Checked for divergence'); + // Step 4: Warn if divergent commits exist + if (originDivergence > 0 || publicDivergence > 0) { + const warnings: string[] = []; + if (originDivergence > 0) { + warnings.push( + ` • origin/${defaultBranch} has ${originDivergence} commit(s) not in upstream` + ); + } + if (publicDivergence > 0) { + warnings.push( + ` • public/${defaultBranch} has ${publicDivergence} commit(s) not in upstream` + ); + } + + p.log.warn('Divergent commits detected:'); p.note( - `Git rebase has conflicts. To resolve: - 1. Fix conflicts in the listed files - 2. Stage resolved files: git add - 3. Continue rebase: git rebase --continue - 4. Or abort: git rebase --abort`, - 'Conflict Resolution' + `${warnings.join('\n')} + +This suggests commits were made directly to the default branch. +Force syncing will LOSE these commits. + +To preserve them: manually rebase or cherry-pick before running sync. +To force sync anyway: git push origin upstream/${defaultBranch}:${defaultBranch} -f`, + '⚠️ Warning' ); - p.outro('⚠️ Please resolve conflicts and continue the rebase'); + p.outro('❌ Sync aborted to prevent data loss'); process.exit(1); } + + // Step 5: Push upstream default branch to origin and public + s.start(`Syncing ${defaultBranch} to origin and public`); + + await $`git push origin upstream/${defaultBranch}:${defaultBranch} --force`; + await $`git push public upstream/${defaultBranch}:${defaultBranch} --force`; + + s.stop('Synced to all remotes'); + + p.outro( + `✨ Sync complete! origin/${defaultBranch} and public/${defaultBranch} are now up to date with upstream/${defaultBranch}` + ); } catch (error) { s.stop('Error occurred'); p.log.error(error instanceof Error ? error.message : String(error)); @@ -445,8 +477,8 @@ venfork status Check which remotes are configured and setup completion venfork sync [branch] - Fetch from upstream and rebase current branch (default: main) - Keeps your private work up-to-date with upstream + Update default branches of origin and public to match upstream + Syncs main/master branch without affecting your current work venfork stage Push branch to public fork for PR to upstream diff --git a/tests/commands.test.ts b/tests/commands.test.ts index daf622c..111d26d 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -301,21 +301,47 @@ describe('setupCommand - execution tests', () => { }); describe('syncCommand', () => { - test('fetches from upstream and rebases', async () => { + test('fetches from all remotes', async () => { try { await syncCommand('main'); } catch { // Expected - may fail in test environment } - // Should have called git fetch and git rebase - const fetchCalls = execaCalls.filter((cmd) => - cmd.includes('git fetch upstream') + // Should have called git fetch for all remotes + const fetchCalls = execaCalls.filter((cmd) => cmd.includes('git fetch')); + + expect(fetchCalls.some((cmd) => cmd.includes('git fetch upstream'))).toBe( + true + ); + expect(fetchCalls.some((cmd) => cmd.includes('git fetch origin'))).toBe( + true + ); + expect(fetchCalls.some((cmd) => cmd.includes('git fetch public'))).toBe( + true ); - const rebaseCalls = execaCalls.filter((cmd) => cmd.includes('git rebase')); + }); + + test('pushes to origin and public default branches', async () => { + try { + await syncCommand('main'); + } catch { + // Expected + } - expect(fetchCalls.length).toBeGreaterThanOrEqual(1); - expect(rebaseCalls.length).toBeGreaterThanOrEqual(1); + // Should push upstream/main to origin/main and public/main + const pushCalls = execaCalls.filter((cmd) => cmd.includes('git push')); + + expect( + pushCalls.some((cmd) => + cmd.includes('git push origin upstream/main:main') + ) + ).toBe(true); + expect( + pushCalls.some((cmd) => + cmd.includes('git push public upstream/main:main') + ) + ).toBe(true); }); test('uses default branch when not specified', async () => { @@ -326,11 +352,22 @@ describe('syncCommand', () => { } // Should call getDefaultBranch (already mocked to return 'main') - const rebaseCalls = execaCalls.filter((cmd) => cmd.includes('git rebase')); - expect(rebaseCalls.length).toBeGreaterThanOrEqual(1); - if (rebaseCalls.length > 0) { - expect(rebaseCalls[0]).toContain('upstream/main'); + const pushCalls = execaCalls.filter((cmd) => cmd.includes('git push')); + expect(pushCalls.length).toBeGreaterThanOrEqual(2); + }); + + test('checks for divergent commits', async () => { + try { + await syncCommand('main'); + } catch { + // Expected } + + // Should call git rev-list to check divergence + const revListCalls = execaCalls.filter((cmd) => + cmd.includes('git rev-list --count') + ); + expect(revListCalls.length).toBeGreaterThanOrEqual(2); // Check origin and public }); }); @@ -524,25 +561,30 @@ describe('setupCommand - error paths', () => { }); describe('syncCommand - error paths', () => { - test('throws error when current branch cannot be determined', async () => { - mockResponses.set('git branch --show-current', { + test('aborts when origin has divergent commits', async () => { + // Mock rev-list to show origin has divergent commits + mockResponses.set('git rev-list --count upstream/main..origin/main', { exitCode: 0, - stdout: '', + stdout: '3', stderr: '', }); try { await syncCommand('main'); - expect(true).toBe(false); // Should not reach here - } catch (error) { - expect(error).toBeDefined(); + } catch { + // Expected - process.exit(1) throws in tests } + + expect(process.exit).toHaveBeenCalledWith(1); }); - test('handles rebase conflicts', async () => { - mockResponses.set('git rebase', () => - Promise.reject(new Error('Rebase conflict')) - ); + test('aborts when public has divergent commits', async () => { + // Mock rev-list to show public has divergent commits + mockResponses.set('git rev-list --count upstream/main..public/main', { + exitCode: 0, + stdout: '2', + stderr: '', + }); try { await syncCommand('main'); @@ -553,7 +595,7 @@ describe('syncCommand - error paths', () => { expect(process.exit).toHaveBeenCalledWith(1); }); - test('handles general errors', async () => { + test('handles fetch errors', async () => { mockResponses.set('git fetch', () => Promise.reject(new Error('Fetch failed')) ); @@ -566,6 +608,20 @@ describe('syncCommand - error paths', () => { expect(process.exit).toHaveBeenCalledWith(1); }); + + test('handles push errors', async () => { + mockResponses.set('git push', () => + Promise.reject(new Error('Push failed')) + ); + + try { + await syncCommand('main'); + } catch { + // Expected + } + + expect(process.exit).toHaveBeenCalledWith(1); + }); }); describe('stageCommand - error paths', () => { From c492817c822878053fc626136c098003fcad1e06 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Sat, 11 Oct 2025 01:16:32 +0100 Subject: [PATCH 05/16] feat: clone command --- README.md | 41 +++++++++- src/commands.ts | 166 ++++++++++++++++++++++++++++++++++++++++- src/index.ts | 4 + src/utils.ts | 15 ++++ tests/commands.test.ts | 98 ++++++++++++++++++++++++ tests/utils.test.ts | 49 +++++++++++- 6 files changed, 368 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 30cd586..fbff8aa 100644 --- a/README.md +++ b/README.md @@ -63,12 +63,15 @@ npx venfork setup ## Quick Start ```bash -# 1. One-time setup (personal account) +# 1a. One-time setup (first team member, personal account) venfork setup git@github.com:awesome/project.git # Or for organization repos venfork setup git@github.com:awesome/project.git --org my-company +# 1b. Clone existing setup (other team members) +venfork clone git@github.com:yourname/project-vendor.git + cd project-vendor # 2. Work privately @@ -119,6 +122,42 @@ venfork setup git@github.com:client/project.git internal-mirror --org my-company # Creates: my-company/internal-mirror (private), my-company/project (public fork) ``` +### `venfork clone ` + +Clone an existing vendor setup and automatically configure all remotes. + +**What it does:** +- Clones the private vendor repository +- Auto-detects the public fork (by stripping `-vendor` suffix) +- Auto-detects the upstream repository (from public fork's parent) +- Configures all three remotes (origin, public, upstream) +- Disables push to upstream (read-only) + +**Use this when:** +- A teammate has already run `venfork setup` +- You need to clone an existing vendor setup +- You want automatic remote configuration + +**Arguments:** +- `vendor-repo-url` - GitHub URL of the private vendor repository (SSH or HTTPS) + +**Examples:** +```bash +# Clone existing vendor setup (personal account) +venfork clone git@github.com:yourname/project-vendor.git +# Auto-detects: public fork at yourname/project +# Auto-detects: upstream from public fork's parent + +# Clone organization vendor setup +venfork clone git@github.com:acme-corp/awesome-project-vendor.git +# Auto-detects: public fork at acme-corp/awesome-project +# Auto-detects: upstream from public fork's parent +``` + +**Interactive prompts:** +- If public fork cannot be auto-detected, you'll be prompted for the URL +- If upstream cannot be auto-detected (no parent), you'll be prompted for the URL + ### `venfork sync [branch]` Update the default branches of your private mirror and public fork to match upstream. diff --git a/src/commands.ts b/src/commands.ts index 02dfc88..88d02b5 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -19,7 +19,7 @@ import { hasRemote, isGitRepository, } from './git.js'; -import { parseRepoName, parseRepoPath } from './utils.js'; +import { parseOwner, parseRepoName, parseRepoPath } from './utils.js'; /** * Setup command: Create private mirror and public fork @@ -173,8 +173,8 @@ export async function setupCommand( p.note(remotesText.trim(), 'Git Remote Configuration'); p.note( - `Private Mirror: https://github.com/${username}/${config.vendorName} (for internal work) -Public Fork: https://github.com/${username}/${publicForkName} (for staging to upstream) + `Private Mirror: https://github.com/${owner}/${config.vendorName} (for internal work) +Public Fork: https://github.com/${owner}/${publicForkName} (for staging to upstream) Upstream: ${config.upstreamUrl} (read-only)`, 'Repositories Created' ); @@ -200,6 +200,158 @@ Upstream: ${config.upstreamUrl} (read-only)`, } } +/** + * Clone command: Clone vendor repository and configure all remotes + */ +export async function cloneCommand(vendorRepoUrl?: string): Promise { + p.intro('🔧 Venfork Clone'); + + // Validate vendor repo URL provided + if (!vendorRepoUrl) { + p.log.error('Vendor repository URL is required'); + p.outro('❌ Clone failed'); + process.exit(1); + } + + // Step 1: Check GitHub CLI authentication + const isAuthenticated = await checkGhAuth(); + if (!isAuthenticated) { + throw new AuthenticationError(); + } + + const s = p.spinner(); + + try { + // Parse vendor repo details + const vendorRepoName = parseRepoName(vendorRepoUrl); + const owner = parseOwner(vendorRepoUrl); + + if (!owner || !vendorRepoName) { + throw new Error('Invalid vendor repository URL'); + } + + // Check if directory already exists + try { + await $`test -d ${vendorRepoName}`; + p.log.error(`Directory '${vendorRepoName}' already exists.`); + p.outro('❌ Clone failed'); + process.exit(1); + } catch { + // Directory doesn't exist, good to proceed + } + + // Step 2: Clone vendor repository + s.start('Cloning vendor repository'); + await $`git clone ${vendorRepoUrl}`; + s.stop('Vendor repository cloned'); + + // Step 3: Auto-detect public fork + s.start('Detecting public fork'); + + // Try to strip -vendor suffix + let publicRepoName = vendorRepoName; + if (vendorRepoName.endsWith('-vendor')) { + publicRepoName = vendorRepoName.replace(/-vendor$/, ''); + } + + // Verify public fork exists + let publicForkUrl: string; + try { + await $`gh repo view ${owner}/${publicRepoName}`; + publicForkUrl = `git@github.com:${owner}/${publicRepoName}.git`; + s.stop(`Found public fork: ${owner}/${publicRepoName}`); + } catch { + s.stop('Public fork not found'); + + p.log.warn('⚠️ Could not auto-detect public fork.'); + p.note(`Tried: ${owner}/${publicRepoName}`, 'Detection Failed'); + + const response = await p.text({ + message: 'Please provide the public fork URL:', + placeholder: 'git@github.com:owner/repo.git', + }); + + if (p.isCancel(response)) { + p.outro('❌ Clone cancelled'); + process.exit(1); + } + + publicForkUrl = response as string; + publicRepoName = parseRepoName(publicForkUrl); + } + + // Step 4: Auto-detect upstream from public fork's parent + s.start('Detecting upstream repository'); + + let upstreamUrl: string; + try { + const result = + await $`gh repo view ${owner}/${publicRepoName} --json parent --jq '.parent.url'`; + upstreamUrl = result.stdout.trim(); + + if (!upstreamUrl || upstreamUrl === 'null') { + throw new Error('No parent found'); + } + + const upstreamPath = parseRepoPath(upstreamUrl); + s.stop(`Found upstream: ${upstreamPath}`); + } catch { + s.stop('Upstream not found'); + + p.log.warn('⚠️ Public fork has no parent repository.'); + + const response = await p.text({ + message: 'Please provide the upstream URL:', + placeholder: 'git@github.com:original/repo.git', + }); + + if (p.isCancel(response)) { + p.outro('❌ Clone cancelled'); + process.exit(1); + } + + upstreamUrl = response as string; + } + + // Step 5: Configure remotes + s.start('Configuring git remotes'); + + // origin is already configured from clone + + // Add public fork remote + await $({ cwd: vendorRepoName })`git remote add public ${publicForkUrl}`; + + // Add upstream remote (with push disabled) + await $({ cwd: vendorRepoName })`git remote add upstream ${upstreamUrl}`; + await $({ + cwd: vendorRepoName, + })`git remote set-url --push upstream DISABLE`; + + s.stop('Git remotes configured'); + + // Step 6: Show configuration + const remotesOutput = await $({ cwd: vendorRepoName })`git remote -v`; + const remotesText = remotesOutput.stdout; + + p.note(remotesText.trim(), 'Git Remote Configuration'); + + // Step 7: Success output + p.outro( + `✨ Clone complete!\n\nNext steps: + cd ${vendorRepoName} + venfork sync # Sync with upstream + git checkout -b feature-branch + # Do your work... + venfork stage feature-branch` + ); + } catch (error) { + s.stop('Error occurred'); + p.log.error(error instanceof Error ? error.message : String(error)); + p.outro('❌ Clone failed'); + process.exit(1); + } +} + /** * Sync command: Update default branches of origin and public to match upstream */ @@ -472,6 +624,14 @@ export function showHelp(): void { • Public fork (yourname/project) - staging for upstream • Configures remotes: origin, public, upstream +venfork clone + Clone an existing vendor setup and configure remotes automatically + + Auto-detects: + • Public fork (strips -vendor suffix) + • Upstream repository (from public fork's parent) + • Configures all three remotes (origin, public, upstream) + venfork status Show current repository setup and configuration Check which remotes are configured and setup completion diff --git a/src/index.ts b/src/index.ts index 307c19c..8d1d835 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import * as p from '@clack/prompts'; import { + cloneCommand, setupCommand, showHelp, stageCommand, @@ -47,6 +48,9 @@ async function main(): Promise { await setupCommand(upstreamUrl, vendorName, organization); break; } + case 'clone': + await cloneCommand(args[1]); + break; case 'sync': await syncCommand(args[1]); break; diff --git a/src/utils.ts b/src/utils.ts index 1202c70..92d3a8b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -30,3 +30,18 @@ export function parseRepoPath(url: string): string { const match = url.match(/github\.com[:/](.+?)(?:\.git)?$/); return match?.[1] || ''; } + +/** + * Extracts owner from a GitHub URL + * + * @param url - GitHub repository URL (SSH or HTTPS) + * @returns Owner/organization name (e.g., "facebook" from "github.com/facebook/react") + * + * @example + * parseOwner("git@github.com:facebook/react.git") // "facebook" + * parseOwner("https://github.com/vercel/next.js.git") // "vercel" + */ +export function parseOwner(url: string): string { + const match = url.match(/github\.com[:/](.+?)\/.+/); + return match?.[1] || ''; +} diff --git a/tests/commands.test.ts b/tests/commands.test.ts index 111d26d..df6a9de 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -108,6 +108,19 @@ function getMockExecaResponse(command: string) { }); } + // GitHub CLI commands for clone + if (command.includes('gh repo view') && command.includes('--json parent')) { + return Promise.resolve({ + exitCode: 0, + stdout: 'https://github.com/upstream/original.git', + stderr: '', + }); + } + if (command.includes('gh repo view')) { + // Checking if public fork exists + return Promise.resolve({ exitCode: 0, stdout: '', stderr: '' }); + } + // For signal handler tests: make fork command hang to prevent cleanup // This keeps setupCommand running so signal handlers remain registered if (command.includes('gh repo fork') && shouldHangOnFork) { @@ -145,6 +158,7 @@ mock.module('@clack/prompts', () => ({ // Import commands (will use mocked execa, fs, and prompts) import { + cloneCommand, setupCommand, showHelp, stageCommand, @@ -560,6 +574,90 @@ describe('setupCommand - error paths', () => { }); }); +describe('cloneCommand', () => { + test('checks authentication first', async () => { + try { + await cloneCommand('git@github.com:acme/project-vendor.git'); + } catch { + // Expected + } + + // Should check authentication + const authCalls = execaCalls.filter((cmd) => + cmd.includes('gh auth status') + ); + expect(authCalls.length).toBeGreaterThan(0); + }); + + test('clones the vendor repository', async () => { + try { + await cloneCommand('git@github.com:acme/project-vendor.git'); + } catch { + // Expected + } + + // Should clone the repo + const cloneCalls = execaCalls.filter((cmd) => cmd.includes('git clone')); + expect(cloneCalls.length).toBeGreaterThan(0); + expect(cloneCalls[0]).toContain('acme/project-vendor'); + }); + + test('detects public fork by stripping -vendor suffix', async () => { + try { + await cloneCommand('git@github.com:acme/project-vendor.git'); + } catch { + // Expected + } + + // Should try to detect public fork + const viewCalls = execaCalls.filter((cmd) => cmd.includes('gh repo view')); + expect(viewCalls.length).toBeGreaterThan(0); + // Should check for 'project' (without -vendor) + expect(viewCalls.some((cmd) => cmd.includes('acme/project'))).toBe(true); + }); + + test('attempts to configure remotes', async () => { + try { + await cloneCommand('git@github.com:acme/project-vendor.git'); + } catch { + // Expected - may fail due to interactive prompts in test environment + } + + // Command should attempt to configure remotes + // Note: Full remote configuration may require interactive input mocking + const remoteCalls = execaCalls.filter((cmd) => cmd.includes('git remote')); + // Should attempt some remote operations + expect(remoteCalls.length).toBeGreaterThan(0); + }); +}); + +describe('cloneCommand - error paths', () => { + test('throws AuthenticationError when not authenticated', async () => { + mockResponses.set('gh auth status', { + exitCode: 1, + stdout: '', + stderr: 'not authenticated', + }); + + try { + await cloneCommand('git@github.com:acme/project-vendor.git'); + expect(true).toBe(false); // Should not reach here + } catch (error) { + expect(error).toBeDefined(); + } + }); + + test('requires vendor repo URL', async () => { + try { + await cloneCommand(); + } catch { + // Expected - process.exit(1) throws + } + + expect(process.exit).toHaveBeenCalledWith(1); + }); +}); + describe('syncCommand - error paths', () => { test('aborts when origin has divergent commits', async () => { // Mock rev-list to show origin has divergent commits diff --git a/tests/utils.test.ts b/tests/utils.test.ts index d6a69fb..818c2d8 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from 'bun:test'; -import { DEFAULT_REPO_NAME, parseRepoName, parseRepoPath } from '../src/utils'; +import { + DEFAULT_REPO_NAME, + parseOwner, + parseRepoName, + parseRepoPath, +} from '../src/utils'; describe('parseRepoName', () => { test('extracts repo name from SSH URL with .git', () => { @@ -96,3 +101,45 @@ describe('parseRepoPath', () => { ); }); }); + +describe('parseOwner', () => { + test('extracts owner from SSH URL with .git', () => { + expect(parseOwner('git@github.com:facebook/react.git')).toBe('facebook'); + }); + + test('extracts owner from SSH URL without .git', () => { + expect(parseOwner('git@github.com:facebook/react')).toBe('facebook'); + }); + + test('extracts owner from HTTPS URL with .git', () => { + expect(parseOwner('https://github.com/vercel/next.js.git')).toBe('vercel'); + }); + + test('extracts owner from HTTPS URL without .git', () => { + expect(parseOwner('https://github.com/vercel/next.js')).toBe('vercel'); + }); + + test('extracts owner with hyphens', () => { + expect(parseOwner('git@github.com:my-company/project.git')).toBe( + 'my-company' + ); + }); + + test('extracts owner with dots', () => { + expect(parseOwner('https://github.com/my.org/project.git')).toBe('my.org'); + }); + + test('returns empty string for invalid URL', () => { + expect(parseOwner('not-a-valid-url')).toBe(''); + }); + + test('returns empty string for empty string', () => { + expect(parseOwner('')).toBe(''); + }); + + test('extracts owner from URL with www', () => { + expect(parseOwner('https://www.github.com/facebook/react.git')).toBe( + 'facebook' + ); + }); +}); From a03089bbf6a25917d61a89ed4eadf73ae9c3b08a Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Sat, 11 Oct 2025 01:21:05 +0100 Subject: [PATCH 06/16] refactor: rename vendorName -> privateMirrorName --- src/commands.ts | 52 +++++++++++++++++++++--------------------- src/index.ts | 6 ++--- tests/commands.test.ts | 4 ++-- 3 files changed, 31 insertions(+), 31 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index 88d02b5..9509d8e 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -26,7 +26,7 @@ import { parseOwner, parseRepoName, parseRepoPath } from './utils.js'; */ export async function setupCommand( upstreamUrl?: string, - vendorName?: string, + privateMirrorName?: string, organization?: string ): Promise { p.intro('🔧 Venfork Setup'); @@ -38,10 +38,10 @@ export async function setupCommand( } // Get configuration from user or use provided arguments - let config: { upstreamUrl: string; vendorName: string }; + let config: { upstreamUrl: string; privateMirrorName: string }; - if (upstreamUrl && vendorName) { - config = { upstreamUrl, vendorName }; + if (upstreamUrl && privateMirrorName) { + config = { upstreamUrl, privateMirrorName }; } else { const groupResult = await p.group( { @@ -55,15 +55,15 @@ export async function setupCommand( if (!value.includes('github.com')) return 'Must be a GitHub URL'; }, }), - vendorName: ({ results }) => + privateMirrorName: ({ results }) => p.text({ - message: 'Private vendor repo name?', + message: 'Private mirror repo name?', placeholder: `${parseRepoName(results.upstreamUrl as string)}-vendor`, defaultValue: - vendorName || + privateMirrorName || `${parseRepoName(results.upstreamUrl as string)}-vendor`, validate: (value) => { - if (!value) return 'Vendor repo name is required'; + if (!value) return 'Private mirror repo name is required'; if (!/^[a-zA-Z0-9-_]+$/.test(value)) return 'Name can only contain letters, numbers, hyphens, and underscores'; }, @@ -76,7 +76,7 @@ export async function setupCommand( }, } ); - config = groupResult as { upstreamUrl: string; vendorName: string }; + config = groupResult as { upstreamUrl: string; privateMirrorName: string }; } const s = p.spinner(); @@ -126,34 +126,34 @@ export async function setupCommand( // Get the public fork name (same as upstream repo name) const publicForkName = parseRepoName(config.upstreamUrl); - // Step 2: Create private vendor repository - s.start('Creating private vendor repository'); - const vendorRepoName = organization - ? `${organization}/${config.vendorName}` - : config.vendorName; - await $`gh repo create ${vendorRepoName} --private --clone=false`; - s.stop('Private vendor repository created'); + // Step 2: Create private mirror repository + s.start('Creating private mirror repository'); + const privateMirrorRepoName = organization + ? `${organization}/${config.privateMirrorName}` + : config.privateMirrorName; + await $`gh repo create ${privateMirrorRepoName} --private --clone=false`; + s.stop('Private mirror repository created'); // Step 3: Clone upstream to temp directory s.start('Cloning upstream repository'); await $`git clone --bare ${config.upstreamUrl} ${tempDir}`; s.stop('Upstream cloned'); - // Step 4: Push to private vendor repo - s.start('Pushing to private vendor repository'); + // Step 4: Push to private mirror repo + s.start('Pushing to private mirror repository'); await $({ cwd: tempDir, - })`git push --mirror git@github.com:${owner}/${config.vendorName}.git`; - s.stop('Pushed to private vendor repository'); + })`git push --mirror git@github.com:${owner}/${config.privateMirrorName}.git`; + s.stop('Pushed to private mirror repository'); - // Step 5: Clone private vendor repo locally - s.start('Cloning private vendor repository locally'); - await $`git clone git@github.com:${owner}/${config.vendorName}.git`; - s.stop('Private vendor repository cloned'); + // Step 5: Clone private mirror repo locally + s.start('Cloning private mirror repository locally'); + await $`git clone git@github.com:${owner}/${config.privateMirrorName}.git`; + s.stop('Private mirror repository cloned'); // Step 6: Configure remotes s.start('Configuring git remotes'); - const repoDir = config.vendorName; + const repoDir = config.privateMirrorName; // Add public fork remote await $({ @@ -173,7 +173,7 @@ export async function setupCommand( p.note(remotesText.trim(), 'Git Remote Configuration'); p.note( - `Private Mirror: https://github.com/${owner}/${config.vendorName} (for internal work) + `Private Mirror: https://github.com/${owner}/${config.privateMirrorName} (for internal work) Public Fork: https://github.com/${owner}/${publicForkName} (for staging to upstream) Upstream: ${config.upstreamUrl} (read-only)`, 'Repositories Created' diff --git a/src/index.ts b/src/index.ts index 8d1d835..c68ab24 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,7 +33,7 @@ async function main(): Promise { const orgIndex = args.indexOf('--org'); let organization: string | undefined; let upstreamUrl = args[1]; - let vendorName = args[2]; + let privateMirrorName = args[2]; if (orgIndex !== -1) { organization = args[orgIndex + 1]; @@ -42,10 +42,10 @@ async function main(): Promise { (_, i) => i !== orgIndex && i !== orgIndex + 1 ); upstreamUrl = filteredArgs[1]; - vendorName = filteredArgs[2]; + privateMirrorName = filteredArgs[2]; } - await setupCommand(upstreamUrl, vendorName, organization); + await setupCommand(upstreamUrl, privateMirrorName, organization); break; } case 'clone': diff --git a/tests/commands.test.ts b/tests/commands.test.ts index df6a9de..b49e7e8 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -172,12 +172,12 @@ import { */ async function startSetupCommand( upstreamUrl = 'git@github.com:test/repo.git', - vendorName = 'test-vendor' + privateMirrorName = 'test-vendor' ): Promise { // Enable fork hanging to keep setupCommand running for signal handler tests shouldHangOnFork = true; - const promise = setupCommand(upstreamUrl, vendorName); + const promise = setupCommand(upstreamUrl, privateMirrorName); // Wait for async operations to complete (checkGhAuth, getGitHubUsername, etc.) // Signal handlers are registered after these complete From fa3f68c6aa9f72f656941e44851e2dde80dd4668 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Sat, 11 Oct 2025 09:40:43 +0100 Subject: [PATCH 07/16] feat: add VENFORK_ORG env variable parsing --- README.md | 39 +++++++++++++++ src/commands.ts | 33 +++++++++++++ src/index.ts | 6 +++ tests/commands.test.ts | 105 ++++++++++++++++++++++++++++++++++++++++- 4 files changed, 181 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fbff8aa..d932d95 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,45 @@ venfork stage bugfix/issue-123 3. Pushes to public fork 4. Provides PR creation link +## Environment Variables + +### `VENFORK_ORG` + +Set a default organization for all venfork commands. This avoids having to type `--org` every time. + +**Priority order:** +1. `--org` flag (highest priority - always overrides) +2. `VENFORK_ORG` environment variable +3. Personal account (prompts for confirmation) + +**Usage:** + +```bash +# Set in your shell profile (~/.zshrc, ~/.bashrc, etc.) +export VENFORK_ORG=my-company + +# Now all commands use this org by default +venfork setup git@github.com:client/project.git +# Creates: my-company/project-vendor (private), my-company/project (public fork) + +# Override with --org flag when needed +venfork setup git@github.com:other-client/app.git --org different-org +# Creates: different-org/app-vendor (private), different-org/app (public fork) +``` + +**Safety feature:** +If neither `--org` nor `VENFORK_ORG` is set, venfork will prompt for confirmation before creating repos under your personal account. This prevents accidental personal repo creation when working as a vendor/contractor. + +```bash +# Without VENFORK_ORG or --org +venfork setup git@github.com:client/project.git + +# Output: +# ⚠️ No organization specified +# Repos will be created under your personal account (username: yourname) +# Continue with personal account? (y/N) +``` + ## Complete Workflow ### Initial Setup diff --git a/src/commands.ts b/src/commands.ts index 9509d8e..41ce19b 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -82,6 +82,24 @@ export async function setupCommand( const s = p.spinner(); const username = await getGitHubUsername(); + // If no organization is specified, confirm before using personal account + if (!organization) { + p.log.warn('⚠️ No organization specified'); + p.log.info( + `Repos will be created under your personal account (username: ${username})` + ); + + const confirmed = await p.confirm({ + message: 'Continue with personal account?', + initialValue: false, + }); + + if (p.isCancel(confirmed) || !confirmed) { + p.outro('❌ Setup cancelled'); + process.exit(0); + } + } + // Determine the account owner (org or user) const owner = organization || username; @@ -668,5 +686,20 @@ venfork stage feature/new-thing 'Example Workflow' ); + p.note( + `VENFORK_ORG - Default organization for repo creation + Set this to avoid typing --org every time + + Priority: + 1. --org flag (highest priority) + 2. VENFORK_ORG environment variable + 3. Personal account (with confirmation prompt) + + Example: + export VENFORK_ORG=my-company + venfork setup # Uses my-company automatically`, + 'Environment Variables' + ); + p.outro('Built for teams who need private vendor workflows'); } diff --git a/src/index.ts b/src/index.ts index c68ab24..b8421f0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,6 +36,7 @@ async function main(): Promise { let privateMirrorName = args[2]; if (orgIndex !== -1) { + // --org flag has highest priority organization = args[orgIndex + 1]; // Remove --org and its value from args const filteredArgs = args.filter( @@ -43,7 +44,12 @@ async function main(): Promise { ); upstreamUrl = filteredArgs[1]; privateMirrorName = filteredArgs[2]; + } else if (process.env.VENFORK_ORG) { + // Fall back to VENFORK_ORG environment variable + organization = process.env.VENFORK_ORG; } + // If neither is set, organization remains undefined + // setupCommand will prompt for confirmation before using personal account await setupCommand(upstreamUrl, privateMirrorName, organization); break; diff --git a/tests/commands.test.ts b/tests/commands.test.ts index b49e7e8..938c334 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -21,6 +21,7 @@ const rmCalls: RmCall[] = []; const signalHandlers = new Map(); let shouldHangOnFork = false; const mockResponses: Map = new Map(); +let confirmResponse = true; // Default to true for most tests // Store originals const originalProcessOn = process.on; @@ -149,10 +150,10 @@ mock.module('@clack/prompts', () => ({ note: mock(() => {}), outro: mock(() => {}), cancel: mock(() => {}), - log: { error: mock(() => {}) }, + log: { error: mock(() => {}), warn: mock(() => {}), info: mock(() => {}) }, group: mock(() => Promise.resolve({})), text: mock(() => Promise.resolve('')), - confirm: mock(() => Promise.resolve(false)), // Mock confirm to return false + confirm: mock(() => Promise.resolve(confirmResponse)), // Use dynamic confirmResponse isCancel: mock(() => false), })); @@ -194,6 +195,10 @@ beforeEach(() => { signalHandlers.clear(); shouldHangOnFork = false; mockResponses.clear(); + confirmResponse = true; // Reset to true for each test + + // Clear VENFORK_ORG environment variable + delete process.env.VENFORK_ORG; // Mock process methods process.on = ((event: string, handler: SignalHandler) => { @@ -540,6 +545,102 @@ describe('setupCommand - organization tests', () => { }); }); +describe('setupCommand - VENFORK_ORG environment variable', () => { + test('uses VENFORK_ORG when no --org flag is present', async () => { + // Simulate what index.ts does: read VENFORK_ORG and pass to setupCommand + process.env.VENFORK_ORG = 'env-org'; + const organization = process.env.VENFORK_ORG; + + try { + await setupCommand( + 'git@github.com:test/repo.git', + 'test-vendor', + organization + ); + } catch { + // Expected + } + + // Should use env-org in commands + const forkCalls = execaCalls.filter((cmd) => cmd.includes('gh repo fork')); + expect(forkCalls.length).toBeGreaterThan(0); + expect(forkCalls[0]).toContain('--org env-org'); + + // Should use env-org in URLs + const cloneCalls = execaCalls.filter((cmd) => cmd.includes('git clone')); + expect(cloneCalls.some((cmd) => cmd.includes('env-org/test-vendor'))).toBe( + true + ); + }); + + test('--org flag overrides VENFORK_ORG', async () => { + process.env.VENFORK_ORG = 'env-org'; + + try { + await setupCommand( + 'git@github.com:test/repo.git', + 'test-vendor', + 'flag-org' + ); + } catch { + // Expected + } + + // Should use flag-org (not env-org) + const forkCalls = execaCalls.filter((cmd) => cmd.includes('gh repo fork')); + expect(forkCalls.length).toBeGreaterThan(0); + expect(forkCalls[0]).toContain('--org flag-org'); + expect(forkCalls[0]).not.toContain('env-org'); + + // Should use flag-org in URLs + const cloneCalls = execaCalls.filter((cmd) => cmd.includes('git clone')); + expect(cloneCalls.some((cmd) => cmd.includes('flag-org/test-vendor'))).toBe( + true + ); + expect(cloneCalls.some((cmd) => cmd.includes('env-org/'))).toBe(false); + }); + + test('prompts for confirmation when neither --org nor VENFORK_ORG is set', async () => { + // Ensure env var is not set + delete process.env.VENFORK_ORG; + // Confirm will return true (from beforeEach default) + + try { + await setupCommand('git@github.com:test/repo.git', 'test-vendor'); + } catch { + // Expected + } + + // Should use testuser (after confirmation) + const cloneCalls = execaCalls.filter((cmd) => cmd.includes('git clone')); + expect(cloneCalls.some((cmd) => cmd.includes('testuser/test-vendor'))).toBe( + true + ); + }); + + test('exits when user declines personal account confirmation', async () => { + // Ensure env var is not set + delete process.env.VENFORK_ORG; + // Set confirm to return false (decline) + confirmResponse = false; + + try { + await setupCommand('git@github.com:test/repo.git', 'test-vendor'); + } catch { + // Expected - command should exit + } + + // Should call process.exit + expect(process.exit).toHaveBeenCalledWith(0); + + // Should NOT create any repos + const createCalls = execaCalls.filter((cmd) => + cmd.includes('gh repo create') + ); + expect(createCalls.length).toBe(0); + }); +}); + describe('setupCommand - error paths', () => { test('throws AuthenticationError when not authenticated', async () => { // Mock checkGhAuth to return false From 384e263d1b015c633c14875568b309c9125a5133 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Sat, 11 Oct 2025 09:42:49 +0100 Subject: [PATCH 08/16] docs: icon --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d932d95..aecce74 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- Venfork Logo + Venfork Logo

# 🔧 Venfork From f2f68be883838ac4e5b97afe1b491b8b16e1372f Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Sun, 12 Oct 2025 13:00:52 +0100 Subject: [PATCH 09/16] refactor: change logo color --- assets/logo.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/logo.svg b/assets/logo.svg index 4b8c252..6b8d6ed 100644 --- a/assets/logo.svg +++ b/assets/logo.svg @@ -1,5 +1,5 @@ - + forking-line From 370937d6653bf541771c9dd3869cf4f7109d47b4 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Sun, 12 Oct 2025 13:32:14 +0100 Subject: [PATCH 10/16] refactor: fix arg parsing --- src/commands.ts | 230 ++++++++++++++++++++++++----------------- src/config.ts | 131 +++++++++++++++++++++++ tests/commands.test.ts | 16 +-- 3 files changed, 276 insertions(+), 101 deletions(-) create mode 100644 src/config.ts diff --git a/src/commands.ts b/src/commands.ts index 41ce19b..0f20952 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -4,6 +4,7 @@ import os from 'node:os'; import path from 'node:path'; import * as p from '@clack/prompts'; import { $ } from 'execa'; +import { createConfigBranch, fetchVenforkConfig } from './config.js'; import { AuthenticationError, BranchNotFoundError, @@ -38,50 +39,63 @@ export async function setupCommand( } // Get configuration from user or use provided arguments - let config: { upstreamUrl: string; privateMirrorName: string }; + let finalUpstreamUrl = upstreamUrl; + let finalPrivateMirrorName = privateMirrorName; + + // Prompt for upstream URL only if not provided + if (!finalUpstreamUrl) { + const response = await p.text({ + message: 'Upstream repository URL?', + placeholder: 'git@github.com:google/project.git', + validate: (value) => { + if (!value) return 'Repository URL is required'; + if (!value.includes('github.com')) return 'Must be a GitHub URL'; + }, + }); - if (upstreamUrl && privateMirrorName) { - config = { upstreamUrl, privateMirrorName }; - } else { - const groupResult = await p.group( - { - upstreamUrl: () => - p.text({ - message: 'Upstream repository URL?', - placeholder: 'git@github.com:google/project.git', - defaultValue: upstreamUrl, - validate: (value) => { - if (!value) return 'Repository URL is required'; - if (!value.includes('github.com')) return 'Must be a GitHub URL'; - }, - }), - privateMirrorName: ({ results }) => - p.text({ - message: 'Private mirror repo name?', - placeholder: `${parseRepoName(results.upstreamUrl as string)}-vendor`, - defaultValue: - privateMirrorName || - `${parseRepoName(results.upstreamUrl as string)}-vendor`, - validate: (value) => { - if (!value) return 'Private mirror repo name is required'; - if (!/^[a-zA-Z0-9-_]+$/.test(value)) - return 'Name can only contain letters, numbers, hyphens, and underscores'; - }, - }), + if (p.isCancel(response)) { + p.cancel('Operation cancelled'); + process.exit(0); + } + + finalUpstreamUrl = response as string; + } + + // Prompt for private mirror name only if not provided + if (!finalPrivateMirrorName) { + const defaultName = `${parseRepoName(finalUpstreamUrl)}-private`; + const response = await p.text({ + message: 'Private mirror repo name?', + placeholder: defaultName, + defaultValue: defaultName, + validate: (value) => { + if (!value) return 'Private mirror repo name is required'; + if (!/^[a-zA-Z0-9-_]+$/.test(value)) + return 'Name can only contain letters, numbers, hyphens, and underscores'; }, - { - onCancel: () => { - p.cancel('Operation cancelled'); - process.exit(0); - }, - } - ); - config = groupResult as { upstreamUrl: string; privateMirrorName: string }; + }); + + if (p.isCancel(response)) { + p.cancel('Operation cancelled'); + process.exit(0); + } + + finalPrivateMirrorName = response as string; } + const config = { + upstreamUrl: finalUpstreamUrl, + privateMirrorName: finalPrivateMirrorName, + }; + const s = p.spinner(); const username = await getGitHubUsername(); + // If organization is the user's personal account, treat it as no organization + if (organization && organization === username) { + organization = undefined; + } + // If no organization is specified, confirm before using personal account if (!organization) { p.log.warn('⚠️ No organization specified'); @@ -184,6 +198,12 @@ export async function setupCommand( s.stop('Git remotes configured'); + // Step 7: Create and push venfork config branch + s.start('Creating venfork configuration'); + const publicForkUrl = `git@github.com:${owner}/${publicForkName}.git`; + await createConfigBranch(repoDir, publicForkUrl, config.upstreamUrl); + s.stop('Venfork configuration created'); + // Show remote configuration const remotesOutput = await $({ cwd: repoDir })`git remote -v`; const remotesText = remotesOutput.stdout; @@ -263,75 +283,99 @@ export async function cloneCommand(vendorRepoUrl?: string): Promise { await $`git clone ${vendorRepoUrl}`; s.stop('Vendor repository cloned'); - // Step 3: Auto-detect public fork - s.start('Detecting public fork'); - - // Try to strip -vendor suffix - let publicRepoName = vendorRepoName; - if (vendorRepoName.endsWith('-vendor')) { - publicRepoName = vendorRepoName.replace(/-vendor$/, ''); - } + // Step 3: Try to fetch venfork config + s.start('Fetching venfork configuration'); + const config = await fetchVenforkConfig(vendorRepoUrl); - // Verify public fork exists let publicForkUrl: string; - try { - await $`gh repo view ${owner}/${publicRepoName}`; - publicForkUrl = `git@github.com:${owner}/${publicRepoName}.git`; - s.stop(`Found public fork: ${owner}/${publicRepoName}`); - } catch { - s.stop('Public fork not found'); + let upstreamUrl: string; - p.log.warn('⚠️ Could not auto-detect public fork.'); - p.note(`Tried: ${owner}/${publicRepoName}`, 'Detection Failed'); + if (config) { + // Config found! Use the URLs from config + publicForkUrl = config.publicForkUrl; + upstreamUrl = config.upstreamUrl; + + const publicRepoPath = parseRepoPath(publicForkUrl); + const upstreamRepoPath = parseRepoPath(upstreamUrl); + + s.stop('Configuration found'); + p.log.success(`✓ Using config from venfork-config branch`); + p.note( + `Public fork: ${publicRepoPath}\nUpstream: ${upstreamRepoPath}`, + 'Configuration' + ); + } else { + // No config found, fall back to auto-detection + s.stop('No configuration found, using auto-detection'); - const response = await p.text({ - message: 'Please provide the public fork URL:', - placeholder: 'git@github.com:owner/repo.git', - }); + // Step 3a: Auto-detect public fork + s.start('Detecting public fork'); - if (p.isCancel(response)) { - p.outro('❌ Clone cancelled'); - process.exit(1); + // Try to strip -private suffix + let publicRepoName = vendorRepoName; + if (vendorRepoName.endsWith('-private')) { + publicRepoName = vendorRepoName.replace(/-private$/, ''); } - publicForkUrl = response as string; - publicRepoName = parseRepoName(publicForkUrl); - } + // Verify public fork exists + try { + await $`gh repo view ${owner}/${publicRepoName}`; + publicForkUrl = `git@github.com:${owner}/${publicRepoName}.git`; + s.stop(`Found public fork: ${owner}/${publicRepoName}`); + } catch { + s.stop('Public fork not found'); - // Step 4: Auto-detect upstream from public fork's parent - s.start('Detecting upstream repository'); + p.log.warn('⚠️ Could not auto-detect public fork.'); + p.note(`Tried: ${owner}/${publicRepoName}`, 'Detection Failed'); - let upstreamUrl: string; - try { - const result = - await $`gh repo view ${owner}/${publicRepoName} --json parent --jq '.parent.url'`; - upstreamUrl = result.stdout.trim(); + const response = await p.text({ + message: 'Please provide the public fork URL:', + placeholder: 'git@github.com:owner/repo.git', + }); + + if (p.isCancel(response)) { + p.outro('❌ Clone cancelled'); + process.exit(1); + } - if (!upstreamUrl || upstreamUrl === 'null') { - throw new Error('No parent found'); + publicForkUrl = response as string; + publicRepoName = parseRepoName(publicForkUrl); } - const upstreamPath = parseRepoPath(upstreamUrl); - s.stop(`Found upstream: ${upstreamPath}`); - } catch { - s.stop('Upstream not found'); + // Step 3b: Auto-detect upstream from public fork's parent + s.start('Detecting upstream repository'); - p.log.warn('⚠️ Public fork has no parent repository.'); + try { + const result = + await $`gh repo view ${owner}/${publicRepoName} --json parent --jq '.parent.url'`; + upstreamUrl = result.stdout.trim(); - const response = await p.text({ - message: 'Please provide the upstream URL:', - placeholder: 'git@github.com:original/repo.git', - }); + if (!upstreamUrl || upstreamUrl === 'null') { + throw new Error('No parent found'); + } - if (p.isCancel(response)) { - p.outro('❌ Clone cancelled'); - process.exit(1); - } + const upstreamPath = parseRepoPath(upstreamUrl); + s.stop(`Found upstream: ${upstreamPath}`); + } catch { + s.stop('Upstream not found'); + + p.log.warn('⚠️ Public fork has no parent repository.'); + + const response = await p.text({ + message: 'Please provide the upstream URL:', + placeholder: 'git@github.com:original/repo.git', + }); - upstreamUrl = response as string; + if (p.isCancel(response)) { + p.outro('❌ Clone cancelled'); + process.exit(1); + } + + upstreamUrl = response as string; + } } - // Step 5: Configure remotes + // Step 4: Configure remotes s.start('Configuring git remotes'); // origin is already configured from clone @@ -347,13 +391,13 @@ export async function cloneCommand(vendorRepoUrl?: string): Promise { s.stop('Git remotes configured'); - // Step 6: Show configuration + // Step 5: Show configuration const remotesOutput = await $({ cwd: vendorRepoName })`git remote -v`; const remotesText = remotesOutput.stdout; p.note(remotesText.trim(), 'Git Remote Configuration'); - // Step 7: Success output + // Step 6: Success output p.outro( `✨ Clone complete!\n\nNext steps: cd ${vendorRepoName} @@ -638,7 +682,7 @@ export function showHelp(): void { • --org Create repos under organization instead of user account Creates: - • Private mirror (yourname/project-vendor) - internal work + • Private mirror (yourname/project-private) - internal work • Public fork (yourname/project) - staging for upstream • Configures remotes: origin, public, upstream @@ -646,7 +690,7 @@ venfork clone Clone an existing vendor setup and configure remotes automatically Auto-detects: - • Public fork (strips -vendor suffix) + • Public fork (strips -private suffix) • Upstream repository (from public fork's parent) • Configures all three remotes (origin, public, upstream) @@ -671,7 +715,7 @@ venfork setup git@github.com:awesome/project.git # Or for organization repos: venfork setup git@github.com:awesome/project.git --org my-company -cd project-vendor +cd project-private # Work privately (juniors can learn here!) git checkout -b feature/new-thing diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..33811c8 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,131 @@ +import { randomBytes } from 'node:crypto'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { $ } from 'execa'; + +/** + * Venfork configuration structure + */ +export interface VenforkConfig { + version: string; + publicForkUrl: string; + upstreamUrl: string; +} + +const CONFIG_BRANCH = 'venfork-config'; +const CONFIG_DIR = '.venfork'; +const CONFIG_FILE = 'config.json'; + +/** + * Creates and pushes a venfork config branch to the origin remote + * + * @param repoDir - Local repository directory + * @param publicForkUrl - URL of the public fork repository + * @param upstreamUrl - URL of the upstream repository + */ +export async function createConfigBranch( + repoDir: string, + publicForkUrl: string, + upstreamUrl: string +): Promise { + // Create config object + const config: VenforkConfig = { + version: '1', + publicForkUrl, + upstreamUrl, + }; + + // Generate unique temp directory + const uniqueId = randomBytes(8).toString('hex'); + const tempDir = path.join(os.tmpdir(), `venfork-config-${uniqueId}`); + + try { + // Create temp directory structure + await mkdir(path.join(tempDir, CONFIG_DIR), { recursive: true }); + + // Write config file + await writeFile( + path.join(tempDir, CONFIG_DIR, CONFIG_FILE), + JSON.stringify(config, null, 2) + ); + + // Initialize git repo in temp directory + await $({ cwd: tempDir })`git init`; + await $({ cwd: tempDir })`git checkout --orphan ${CONFIG_BRANCH}`; + + // Commit the config + await $({ cwd: tempDir })`git add ${CONFIG_DIR}/${CONFIG_FILE}`; + await $({ + cwd: tempDir, + })`git commit -m ${'Initialize venfork configuration'}`; + + // Get the origin remote URL from the main repo + const remoteResult = await $({ cwd: repoDir })`git remote get-url origin`; + const originUrl = remoteResult.stdout.trim(); + + // Push to origin + await $({ + cwd: tempDir, + })`git push ${originUrl} ${CONFIG_BRANCH}:${CONFIG_BRANCH} --force`; + } finally { + // Clean up temp directory + try { + await rm(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + } +} + +/** + * Fetches and reads the venfork config from a repository + * + * @param repoUrl - Repository URL to fetch config from + * @returns Config object if found, null otherwise + */ +export async function fetchVenforkConfig( + repoUrl: string +): Promise { + // Generate unique temp directory + const uniqueId = randomBytes(8).toString('hex'); + const tempDir = path.join(os.tmpdir(), `venfork-config-read-${uniqueId}`); + + try { + // Try to clone just the config branch + const cloneResult = await $({ + reject: false, + })`git clone --branch ${CONFIG_BRANCH} --single-branch --depth 1 ${repoUrl} ${tempDir}`; + + if (cloneResult.exitCode !== 0) { + // Config branch doesn't exist + return null; + } + + // Read the config file + const configPath = path.join(tempDir, CONFIG_DIR, CONFIG_FILE); + const readResult = await $({ reject: false })`cat ${configPath}`; + + if (readResult.exitCode !== 0) { + return null; + } + + const config = JSON.parse(readResult.stdout) as VenforkConfig; + + // Validate config structure + if (!config.version || !config.publicForkUrl || !config.upstreamUrl) { + return null; + } + + return config; + } catch { + return null; + } finally { + // Clean up temp directory + try { + await rm(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + } +} diff --git a/tests/commands.test.ts b/tests/commands.test.ts index 938c334..e862c52 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -678,7 +678,7 @@ describe('setupCommand - error paths', () => { describe('cloneCommand', () => { test('checks authentication first', async () => { try { - await cloneCommand('git@github.com:acme/project-vendor.git'); + await cloneCommand('git@github.com:acme/project-private.git'); } catch { // Expected } @@ -692,7 +692,7 @@ describe('cloneCommand', () => { test('clones the vendor repository', async () => { try { - await cloneCommand('git@github.com:acme/project-vendor.git'); + await cloneCommand('git@github.com:acme/project-private.git'); } catch { // Expected } @@ -700,12 +700,12 @@ describe('cloneCommand', () => { // Should clone the repo const cloneCalls = execaCalls.filter((cmd) => cmd.includes('git clone')); expect(cloneCalls.length).toBeGreaterThan(0); - expect(cloneCalls[0]).toContain('acme/project-vendor'); + expect(cloneCalls[0]).toContain('acme/project-private'); }); - test('detects public fork by stripping -vendor suffix', async () => { + test('detects public fork by stripping -private suffix', async () => { try { - await cloneCommand('git@github.com:acme/project-vendor.git'); + await cloneCommand('git@github.com:acme/project-private.git'); } catch { // Expected } @@ -713,13 +713,13 @@ describe('cloneCommand', () => { // Should try to detect public fork const viewCalls = execaCalls.filter((cmd) => cmd.includes('gh repo view')); expect(viewCalls.length).toBeGreaterThan(0); - // Should check for 'project' (without -vendor) + // Should check for 'project' (without -private) expect(viewCalls.some((cmd) => cmd.includes('acme/project'))).toBe(true); }); test('attempts to configure remotes', async () => { try { - await cloneCommand('git@github.com:acme/project-vendor.git'); + await cloneCommand('git@github.com:acme/project-private.git'); } catch { // Expected - may fail due to interactive prompts in test environment } @@ -741,7 +741,7 @@ describe('cloneCommand - error paths', () => { }); try { - await cloneCommand('git@github.com:acme/project-vendor.git'); + await cloneCommand('git@github.com:acme/project-private.git'); expect(true).toBe(false); // Should not reach here } catch (error) { expect(error).toBeDefined(); From c0238a33b992a18308a4c642877aadd931228366 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Sun, 12 Oct 2025 13:35:33 +0100 Subject: [PATCH 11/16] Version Packages --- .changeset/initial-release.md | 5 ----- CHANGELOG.md | 24 ++++++++++++++++++++++++ package.json | 2 +- 3 files changed, 25 insertions(+), 6 deletions(-) delete mode 100644 .changeset/initial-release.md diff --git a/.changeset/initial-release.md b/.changeset/initial-release.md deleted file mode 100644 index f6ad5ac..0000000 --- a/.changeset/initial-release.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"venfork": patch ---- - -Initial release of venfork - Create and manage private mirrors for vendor development workflows. Includes setup, sync, stage, and status commands. diff --git a/CHANGELOG.md b/CHANGELOG.md index 73116f6..6ec0a20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # venfork +## 0.1.0 + +### Minor Changes + +- **New Features:** + + - Add venfork-config branch for reliable clone detection - stores publicForkUrl and upstreamUrl in `.venfork/config.json` on a dedicated orphan branch + - Clone command now reads config first, falling back to auto-detection if not found + + **Improvements:** + + - Change default suffix from `-vendor` to `-private` for private mirror repositories + - Fix personal account handling - passing `--org` with your personal username now works correctly + - Improve setup prompts to only ask for missing values instead of always prompting + + **Bug Fixes:** + + - Fix GitHub API error when using personal account with `--org` flag + - Fix redundant upstream URL prompt when provided as command argument + +### Patch Changes + +- [`86a246b`](https://github.com/cabljac/venfork/commit/86a246b5bf9c39463854c45d514d3e8edce6df38) Thanks [@cabljac](https://github.com/cabljac)! - Initial release of venfork - Create and manage private mirrors for vendor development workflows. Includes setup, sync, stage, and status commands. + ## 0.0.1 ### Patch Changes diff --git a/package.json b/package.json index 3ef84ff..5ede9e0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "venfork", - "version": "0.0.1", + "version": "0.1.0", "description": "Create and manage private mirrors for vendor development", "type": "module", "bin": { From b3e14c6bb52fcd2199dd49565105b1fa7473cd20 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Sun, 12 Oct 2025 17:52:35 +0100 Subject: [PATCH 12/16] docs: update README for v0.1.0 changes - Change default suffix from -vendor to -private - Document venfork-config branch feature - Update all examples and diagrams --- README.md | 48 ++++++++++++++++++++++++++---------------------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index aecce74..2849568 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Create and manage private mirrors of public GitHub repositories for vendor devel Venfork helps contractors and vendors who need to work on private forks of public repositories. It creates a **three-repository workflow**: -1. **Private Mirror** (`yourname/project-vendor` or `org/project-vendor`) - Where your team works internally +1. **Private Mirror** (`yourname/project-private` or `org/project-private`) - Where your team works internally 2. **Public Fork** (`yourname/project` or `org/project`) - Staging area for contributions to upstream 3. **Upstream** (`original/project`) - The original repository @@ -70,9 +70,9 @@ venfork setup git@github.com:awesome/project.git venfork setup git@github.com:awesome/project.git --org my-company # 1b. Clone existing setup (other team members) -venfork clone git@github.com:yourname/project-vendor.git +venfork clone git@github.com:yourname/project-private.git -cd project-vendor +cd project-private # 2. Work privately git checkout -b feature/new-thing @@ -93,8 +93,9 @@ venfork stage feature/new-thing Creates the complete vendor workflow setup: **What it creates:** -- **Private mirror** (`yourname/project-vendor` or `org/project-vendor`) - For internal work +- **Private mirror** (`yourname/project-private` or `org/project-private`) - For internal work - **Public fork** (`yourname/project` or `org/project`) - For staging to upstream +- **Config branch** (`venfork-config`) - Stores remote URLs for easy team cloning - **Local clone** with three remotes configured: - `origin` → private mirror (default push/pull) - `public` → public fork (for staging) @@ -102,21 +103,21 @@ Creates the complete vendor workflow setup: **Arguments:** - `upstream-url` - GitHub repository URL (SSH or HTTPS) -- `name` - (Optional) Name for private vendor repo (default: `{repo}-vendor`) +- `name` - (Optional) Name for private mirror repo (default: `{repo}-private`) - `--org ` - (Optional) Create repos under organization instead of personal account **Examples:** ```bash # Personal account (default) venfork setup git@github.com:vercel/next.js.git -# Creates: yourname/next.js-vendor (private), yourname/next.js (public fork) +# Creates: yourname/next.js-private (private), yourname/next.js (public fork) venfork setup https://github.com/vuejs/vue.git vue-internal # Creates: yourname/vue-internal (private), yourname/vue (public fork) # Organization account venfork setup git@github.com:client/awesome-project.git --org acme-corp -# Creates: acme-corp/awesome-project-vendor (private), acme-corp/awesome-project (public fork) +# Creates: acme-corp/awesome-project-private (private), acme-corp/awesome-project (public fork) venfork setup git@github.com:client/project.git internal-mirror --org my-company # Creates: my-company/internal-mirror (private), my-company/project (public fork) @@ -127,9 +128,11 @@ venfork setup git@github.com:client/project.git internal-mirror --org my-company Clone an existing vendor setup and automatically configure all remotes. **What it does:** -- Clones the private vendor repository -- Auto-detects the public fork (by stripping `-vendor` suffix) -- Auto-detects the upstream repository (from public fork's parent) +- Clones the private mirror repository +- **Reads venfork-config branch** for public fork and upstream URLs (if available) +- Falls back to auto-detection: + - Public fork (by stripping `-private` suffix) + - Upstream repository (from public fork's parent) - Configures all three remotes (origin, public, upstream) - Disables push to upstream (read-only) @@ -144,14 +147,14 @@ Clone an existing vendor setup and automatically configure all remotes. **Examples:** ```bash # Clone existing vendor setup (personal account) -venfork clone git@github.com:yourname/project-vendor.git -# Auto-detects: public fork at yourname/project -# Auto-detects: upstream from public fork's parent +venfork clone git@github.com:yourname/project-private.git +# Reads config from venfork-config branch (if available) +# Or auto-detects: public fork at yourname/project # Clone organization vendor setup -venfork clone git@github.com:acme-corp/awesome-project-vendor.git -# Auto-detects: public fork at acme-corp/awesome-project -# Auto-detects: upstream from public fork's parent +venfork clone git@github.com:acme-corp/awesome-project-private.git +# Reads config from venfork-config branch (if available) +# Or auto-detects: public fork at acme-corp/awesome-project ``` **Interactive prompts:** @@ -275,7 +278,7 @@ venfork setup git@github.com:client/awesome-project.git venfork setup git@github.com:client/awesome-project.git --org acme-corp # Navigate to private mirror -cd awesome-project-vendor +cd awesome-project-private # Check setup status venfork status @@ -283,12 +286,12 @@ venfork status # Or verify remotes manually git remote -v # With personal account: -# origin git@github.com:you/awesome-project-vendor.git (private) +# origin git@github.com:you/awesome-project-private.git (private) # public git@github.com:you/awesome-project.git (public fork) # upstream git@github.com:client/awesome-project.git (read-only) # With organization: -# origin git@github.com:acme-corp/awesome-project-vendor.git (private) +# origin git@github.com:acme-corp/awesome-project-private.git (private) # public git@github.com:acme-corp/awesome-project.git (public fork) # upstream git@github.com:client/awesome-project.git (read-only) ``` @@ -361,12 +364,13 @@ venfork stage feature/user-auth │ mirror (disconnected) ▼ ┌─────────────────────────────────────────────┐ -│ Private Mirror (you/project-vendor) │ -│ (or org/project-vendor) │ +│ Private Mirror (you/project-private) │ +│ (or org/project-private) │ │ • Only visible to your team │ │ • Safe space to experiment & iterate │ │ • Internal PRs and reviews │ │ • Your daily work happens here │ +│ • Contains venfork-config branch │ └─────────────────────────────────────────────┘ ``` @@ -378,7 +382,7 @@ After `venfork setup`, your local repository has three remotes: | Remote | URL | Purpose | |--------|-----|---------| -| `origin` | `you/project-vendor` (or `org/project-vendor`) | Private work (default) | +| `origin` | `you/project-private` (or `org/project-private`) | Private work (default) | | `public` | `you/project` (or `org/project`) | Stage for upstream | | `upstream` | `original/project` | Sync with latest | From 7fd21056d8f95df004f5f1ed35a0e04a3a602b87 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Sun, 12 Oct 2025 17:56:24 +0100 Subject: [PATCH 13/16] Version Packages --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ec0a20..1662353 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # venfork +## 0.1.1 + +### Patch Changes + +- Update README documentation to reflect v0.1.0 changes - update all examples from `-vendor` to `-private` suffix and document new venfork-config branch feature + ## 0.1.0 ### Minor Changes diff --git a/package.json b/package.json index 5ede9e0..27101bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "venfork", - "version": "0.1.0", + "version": "0.1.1", "description": "Create and manage private mirrors for vendor development", "type": "module", "bin": { From 61a2e5454dbb04d089898ab2837e0f247954c3f8 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Mon, 13 Oct 2025 10:21:47 +0100 Subject: [PATCH 14/16] perf: optimize setup to clone only default branch and fix --org= parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Changed from full repository mirror to cloning only default branch - Dramatically reduces setup time for large repositories - Fixed argument parsing to support both --org value and --org=value formats 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .changeset/faster-setup-and-arg-fix.md | 8 ++++++ src/commands.ts | 30 +++++++++++++++++------ src/index.ts | 34 +++++++++++++++++--------- 3 files changed, 53 insertions(+), 19 deletions(-) create mode 100644 .changeset/faster-setup-and-arg-fix.md diff --git a/.changeset/faster-setup-and-arg-fix.md b/.changeset/faster-setup-and-arg-fix.md new file mode 100644 index 0000000..4403aba --- /dev/null +++ b/.changeset/faster-setup-and-arg-fix.md @@ -0,0 +1,8 @@ +--- +"venfork": minor +--- + +Performance improvements and bug fixes: + +- **Faster setup**: Changed from full repository mirror to cloning only the default branch, dramatically reducing setup time for large repositories +- **Fixed argument parsing**: Now supports both `--org value` and `--org=value` formats for the organization flag diff --git a/src/commands.ts b/src/commands.ts index 0f20952..7adc4f2 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -168,22 +168,38 @@ export async function setupCommand( // Step 3: Clone upstream to temp directory s.start('Cloning upstream repository'); - await $`git clone --bare ${config.upstreamUrl} ${tempDir}`; + await $`git clone ${config.upstreamUrl} ${tempDir}`; s.stop('Upstream cloned'); - // Step 4: Push to private mirror repo - s.start('Pushing to private mirror repository'); + // Step 4: Detect default branch from upstream + s.start('Detecting default branch'); + const result = await $({ + cwd: tempDir, + reject: false, + })`git symbolic-ref refs/remotes/origin/HEAD`; + + let defaultBranch = 'main'; + if (result.exitCode === 0) { + const match = result.stdout.trim().match(/refs\/remotes\/origin\/(.+)$/); + if (match?.[1]) { + defaultBranch = match[1]; + } + } + s.stop(`Default branch: ${defaultBranch}`); + + // Step 5: Push default branch to private mirror repo + s.start(`Pushing ${defaultBranch} to private mirror repository`); await $({ cwd: tempDir, - })`git push --mirror git@github.com:${owner}/${config.privateMirrorName}.git`; + })`git push git@github.com:${owner}/${config.privateMirrorName}.git ${defaultBranch}:${defaultBranch}`; s.stop('Pushed to private mirror repository'); - // Step 5: Clone private mirror repo locally + // Step 6: Clone private mirror repo locally s.start('Cloning private mirror repository locally'); await $`git clone git@github.com:${owner}/${config.privateMirrorName}.git`; s.stop('Private mirror repository cloned'); - // Step 6: Configure remotes + // Step 7: Configure remotes s.start('Configuring git remotes'); const repoDir = config.privateMirrorName; @@ -198,7 +214,7 @@ export async function setupCommand( s.stop('Git remotes configured'); - // Step 7: Create and push venfork config branch + // Step 8: Create and push venfork config branch s.start('Creating venfork configuration'); const publicForkUrl = `git@github.com:${owner}/${publicForkName}.git`; await createConfigBranch(repoDir, publicForkUrl, config.upstreamUrl); diff --git a/src/index.ts b/src/index.ts index b8421f0..7027b86 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,24 +29,34 @@ async function main(): Promise { switch (command) { case 'setup': { - // Parse --org flag - const orgIndex = args.indexOf('--org'); + // Parse --org flag (supports both --org value and --org=value) let organization: string | undefined; let upstreamUrl = args[1]; let privateMirrorName = args[2]; - if (orgIndex !== -1) { - // --org flag has highest priority - organization = args[orgIndex + 1]; - // Remove --org and its value from args - const filteredArgs = args.filter( - (_, i) => i !== orgIndex && i !== orgIndex + 1 - ); + // Check for --org=value format + const orgEqualArg = args.find((arg) => arg.startsWith('--org=')); + if (orgEqualArg) { + organization = orgEqualArg.split('=')[1]; + // Remove --org=value from args + const filteredArgs = args.filter((arg) => !arg.startsWith('--org=')); upstreamUrl = filteredArgs[1]; privateMirrorName = filteredArgs[2]; - } else if (process.env.VENFORK_ORG) { - // Fall back to VENFORK_ORG environment variable - organization = process.env.VENFORK_ORG; + } else { + // Check for --org value format + const orgIndex = args.indexOf('--org'); + if (orgIndex !== -1) { + organization = args[orgIndex + 1]; + // Remove --org and its value from args + const filteredArgs = args.filter( + (_, i) => i !== orgIndex && i !== orgIndex + 1 + ); + upstreamUrl = filteredArgs[1]; + privateMirrorName = filteredArgs[2]; + } else if (process.env.VENFORK_ORG) { + // Fall back to VENFORK_ORG environment variable + organization = process.env.VENFORK_ORG; + } } // If neither is set, organization remains undefined // setupCommand will prompt for confirmation before using personal account From 9559ef42080b92d8be9d3052c065e409bbc5eab8 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Mon, 13 Oct 2025 10:25:55 +0100 Subject: [PATCH 15/16] Version Packages --- .changeset/faster-setup-and-arg-fix.md | 8 -------- CHANGELOG.md | 9 +++++++++ package.json | 2 +- 3 files changed, 10 insertions(+), 9 deletions(-) delete mode 100644 .changeset/faster-setup-and-arg-fix.md diff --git a/.changeset/faster-setup-and-arg-fix.md b/.changeset/faster-setup-and-arg-fix.md deleted file mode 100644 index 4403aba..0000000 --- a/.changeset/faster-setup-and-arg-fix.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -"venfork": minor ---- - -Performance improvements and bug fixes: - -- **Faster setup**: Changed from full repository mirror to cloning only the default branch, dramatically reducing setup time for large repositories -- **Fixed argument parsing**: Now supports both `--org value` and `--org=value` formats for the organization flag diff --git a/CHANGELOG.md b/CHANGELOG.md index 1662353..d58b0ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # venfork +## 0.2.0 + +### Minor Changes + +- [`61a2e54`](https://github.com/cabljac/venfork/commit/61a2e5454dbb04d089898ab2837e0f247954c3f8) Thanks [@cabljac](https://github.com/cabljac)! - Performance improvements and bug fixes: + + - **Faster setup**: Changed from full repository mirror to cloning only the default branch, dramatically reducing setup time for large repositories + - **Fixed argument parsing**: Now supports both `--org value` and `--org=value` formats for the organization flag + ## 0.1.1 ### Patch Changes diff --git a/package.json b/package.json index 27101bf..2d29687 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "venfork", - "version": "0.1.1", + "version": "0.2.0", "description": "Create and manage private mirrors for vendor development", "type": "module", "bin": { From df25b62443443c689791c593128e6cebc604bf65 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 Nov 2025 00:04:57 +0000 Subject: [PATCH 16/16] ci: bump actions/checkout from 4 to 6 Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e015a27..8daf7ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Bun uses: oven-sh/setup-bun@v1 @@ -64,7 +64,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node.js uses: actions/setup-node@v4