-
Notifications
You must be signed in to change notification settings - Fork 28
feat(VEG-3619): Add connector bump command in zcli #325
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| import { Command, Flags } from '@oclif/core' | ||
| import * as chalk from 'chalk' | ||
| import * as semver from 'semver' | ||
| import { existsSync, readFileSync, writeFileSync } from 'fs' | ||
| import { join, resolve } from 'path' | ||
|
|
||
| export default class Bump extends Command { | ||
| static description = 'bumps the version of your connector in the manifest. Accepts major, minor and patch; defaults to patch.' | ||
|
|
||
| static args = [ | ||
| { name: 'path', description: 'relative path to connector root directory (optional, defaults to current directory)' } | ||
| ] | ||
|
|
||
| static examples = [ | ||
| '<%= config.bin %> <%= command.id %>', | ||
| '<%= config.bin %> <%= command.id %> ./my-connector', | ||
| '<%= config.bin %> <%= command.id %> -M ./my-connector', | ||
| '<%= config.bin %> <%= command.id %> -m ./my-connector', | ||
| '<%= config.bin %> <%= command.id %> -p ./my-connector' | ||
| ] | ||
|
|
||
| static flags = { | ||
| help: Flags.help({ char: 'h' }), | ||
| major: Flags.boolean({ char: 'M', description: 'Increments the major version by 1' }), | ||
| minor: Flags.boolean({ char: 'm', description: 'Increments the minor version by 1' }), | ||
| patch: Flags.boolean({ char: 'p', description: 'Increments the patch version by 1' }) | ||
| } | ||
zendesk-vishesh marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| async run (): Promise<void> { | ||
| const { args, flags } = await this.parse(Bump) | ||
| const { major, minor } = flags | ||
| const connectorPath = resolve(args.path || '.') | ||
|
|
||
| // Validate connector directory exists | ||
| if (!existsSync(connectorPath)) { | ||
| this.error(chalk.red(`Error: Directory ${connectorPath} does not exist`)) | ||
| } | ||
|
|
||
| const indexTsPath = join(connectorPath, 'src/index.ts') | ||
|
|
||
| // Validate index.ts exists | ||
| if (!existsSync(indexTsPath)) { | ||
| this.error(chalk.red(`Error: Could not find src/index.ts in ${connectorPath}`)) | ||
| } | ||
|
|
||
| try { | ||
| let content = readFileSync(indexTsPath, 'utf8') | ||
|
|
||
| // Extract current version using regex | ||
| const versionRegex = /version:\s*['"]([^'"]+)['"]/ | ||
| const match = content.match(versionRegex) | ||
|
|
||
| if (!match) { | ||
| throw new Error('Could not find version field in src/index.ts. Make sure your connector manifest includes a version field.') | ||
| } | ||
zendesk-vishesh marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| const currentVersion = match[1] | ||
|
|
||
| // Validate current version is valid semver | ||
| if (!semver.valid(currentVersion)) { | ||
| throw new Error(`Current version '${currentVersion}' is not a valid semantic version`) | ||
| } | ||
|
|
||
| // Calculate new version | ||
| let newVersion: string | null | ||
| if (major) { | ||
| newVersion = semver.inc(currentVersion, 'major') | ||
| } else if (minor) { | ||
| newVersion = semver.inc(currentVersion, 'minor') | ||
| } else { | ||
| newVersion = semver.inc(currentVersion, 'patch') | ||
| } | ||
|
|
||
| if (!newVersion) { | ||
| throw new Error('Failed to increment version') | ||
| } | ||
|
|
||
| // Replace version in content while preserving original formatting and quotes | ||
| const originalContent = content | ||
| const fullMatch = match[0] | ||
| const updatedMatch = fullMatch.replace(currentVersion, newVersion) | ||
| content = content.replace(fullMatch, updatedMatch) | ||
|
|
||
| // Verify that the content was actually changed | ||
| if (content === originalContent) { | ||
| throw new Error('Failed to update version in src/index.ts') | ||
| } | ||
|
|
||
| // Write updated content back to file | ||
| writeFileSync(indexTsPath, content, 'utf8') | ||
|
|
||
| this.log(chalk.green(`✅ Successfully bumped connector version from ${currentVersion} to ${newVersion}`)) | ||
| } catch (error) { | ||
| const errorMessage = error instanceof Error ? error.message : String(error) | ||
| const formattedMessage = errorMessage.trim().startsWith('Error:') ? errorMessage : `Error: ${errorMessage}` | ||
| this.error(chalk.red(formattedMessage)) | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -46,7 +46,8 @@ export default class Create extends Command { | |||||||||||
| replaceInFile(indexTsPath, { | ||||||||||||
| "name: 'starter'": `name: '${connector}'`, | ||||||||||||
| "title: 'Starter Connector'": `title: '${toTitleCase(connector)}'`, | ||||||||||||
| "description: 'Starter Connector'": `description: '${toTitleCase(connector)} connector'` | ||||||||||||
| "description: 'Starter Connector'": `description: '${toTitleCase(connector)} connector'`, | ||||||||||||
| "version: '0.0.1'": "version: '0.0.1'" | ||||||||||||
| }) | ||||||||||||
|
|
||||||||||||
|
Comment on lines
+49
to
52
|
||||||||||||
| "description: 'Starter Connector'": `description: '${toTitleCase(connector)} connector'`, | |
| "version: '0.0.1'": "version: '0.0.1'" | |
| }) | |
| "description: 'Starter Connector'": `description: '${toTitleCase(connector)} connector'` | |
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
While this is technically a no-op replacement, I'd like to keep it for the following reasons.
First, it acts as an explicit contract that documents version as a required manifest field that the create command is aware of and intentionally managing, not accidentally leaving untouched.
Second, it maintains consistency since all manifest fields (name, title, description, version) follow the same pattern in replaceInFile, making the code predictable and easier to understand at a glance.
Third, it's future-proof - if we later need to change the default version (e.g., '1.0.0' for production connectors, or parameterized versions), we have a single, obvious place to modify it alongside other manifest defaults without hunting through the codebase.
Finally, this mirrors the explicit field management approach used in other ZCLI create commands. The minimal cost (one line) is worth the clarity and maintainability benefits, but if there's a strong preference to remove it, I'm happy to do so.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,243 @@ | ||
| /* eslint-disable no-unused-expressions */ | ||
|
|
||
| import { expect, use } from 'chai' | ||
| import * as sinon from 'sinon' | ||
| import sinonChai from 'sinon-chai' | ||
| import * as fs from 'fs' | ||
| import * as path from 'path' | ||
| import BumpCommand from '../../src/commands/connectors/bump' | ||
|
|
||
| use(sinonChai) | ||
|
|
||
| describe('bump', () => { | ||
| let bumpCommand: BumpCommand | ||
| let fsStubs: any | ||
| let logStub: sinon.SinonStub | ||
| let testDir: string | ||
|
|
||
| const validIndexTsContent = `import { manifest } from '@zendesk/connector-sdk'; | ||
|
|
||
| const connector = manifest({ | ||
| name: 'test-connector', | ||
| title: 'Test Connector', | ||
| version: '1.0.0', | ||
| authentication: {}, | ||
| actions: [], | ||
| }); | ||
|
|
||
| export default connector; | ||
| ` | ||
|
|
||
| const doubleQuoteIndexTsContent = `import { manifest } from '@zendesk/connector-sdk'; | ||
|
|
||
| const connector = manifest({ | ||
| name: 'test-connector', | ||
| title: 'Test Connector', | ||
| version: "1.0.0", | ||
| authentication: {}, | ||
| actions: [], | ||
| }); | ||
|
|
||
| export default connector; | ||
| ` | ||
|
|
||
| const invalidVersionIndexTsContent = `import { manifest } from '@zendesk/connector-sdk'; | ||
|
|
||
| const connector = manifest({ | ||
| name: 'test-connector', | ||
| title: 'Test Connector', | ||
| version: '1.0', | ||
| authentication: {}, | ||
| actions: [], | ||
| }); | ||
|
|
||
| export default connector; | ||
| ` | ||
|
|
||
| const missingVersionIndexTsContent = `import { manifest } from '@zendesk/connector-sdk'; | ||
|
|
||
| const connector = manifest({ | ||
| name: 'test-connector', | ||
| title: 'Test Connector', | ||
| authentication: {}, | ||
| actions: [], | ||
| }); | ||
|
|
||
| export default connector; | ||
| ` | ||
|
|
||
| beforeEach(() => { | ||
| testDir = path.resolve('test', 'connector') | ||
|
|
||
| bumpCommand = new BumpCommand([], {} as any) | ||
| logStub = sinon.stub(bumpCommand, 'log') | ||
|
|
||
| fsStubs = { | ||
| existsSync: sinon.stub(fs, 'existsSync'), | ||
| readFileSync: sinon.stub(fs, 'readFileSync'), | ||
| writeFileSync: sinon.stub(fs, 'writeFileSync') | ||
| } | ||
| }) | ||
|
|
||
| afterEach(() => { | ||
| sinon.restore() | ||
| }) | ||
|
|
||
| describe('with valid version', () => { | ||
| beforeEach(() => { | ||
| fsStubs.existsSync.returns(true) | ||
| fsStubs.readFileSync.returns(validIndexTsContent) | ||
| }) | ||
|
|
||
| it('should bump patch version by default', async () => { | ||
| sinon.stub(bumpCommand, 'parse' as any).resolves({ | ||
| args: { path: testDir }, | ||
| flags: {} | ||
| }) | ||
|
|
||
| await bumpCommand.run() | ||
|
|
||
| expect(fsStubs.writeFileSync).to.have.been.calledOnce | ||
| const writtenContent = fsStubs.writeFileSync.firstCall.args[1] | ||
| expect(writtenContent).to.include("version: '1.0.1'") | ||
| expect(logStub).to.have.been.calledWith(sinon.match(/1\.0\.0 to 1\.0\.1/)) | ||
| }) | ||
|
|
||
| it('should bump patch version with -p flag', async () => { | ||
| sinon.stub(bumpCommand, 'parse' as any).resolves({ | ||
| args: { path: testDir }, | ||
| flags: { patch: true } | ||
| }) | ||
|
|
||
| await bumpCommand.run() | ||
|
|
||
| const writtenContent = fsStubs.writeFileSync.firstCall.args[1] | ||
| expect(writtenContent).to.include("version: '1.0.1'") | ||
| expect(logStub).to.have.been.calledWith(sinon.match(/1\.0\.0 to 1\.0\.1/)) | ||
| }) | ||
|
|
||
| it('should bump minor version with -m flag', async () => { | ||
| sinon.stub(bumpCommand, 'parse' as any).resolves({ | ||
| args: { path: testDir }, | ||
| flags: { minor: true } | ||
| }) | ||
|
|
||
| await bumpCommand.run() | ||
|
|
||
| const writtenContent = fsStubs.writeFileSync.firstCall.args[1] | ||
| expect(writtenContent).to.include("version: '1.1.0'") | ||
| expect(logStub).to.have.been.calledWith(sinon.match(/1\.0\.0 to 1\.1\.0/)) | ||
| }) | ||
|
|
||
| it('should bump major version with -M flag', async () => { | ||
| sinon.stub(bumpCommand, 'parse' as any).resolves({ | ||
| args: { path: testDir }, | ||
| flags: { major: true } | ||
| }) | ||
|
|
||
| await bumpCommand.run() | ||
|
|
||
| const writtenContent = fsStubs.writeFileSync.firstCall.args[1] | ||
| expect(writtenContent).to.include("version: '2.0.0'") | ||
| expect(logStub).to.have.been.calledWith(sinon.match(/1\.0\.0 to 2\.0\.0/)) | ||
| }) | ||
|
|
||
| it('should preserve double quotes when present', async () => { | ||
| fsStubs.readFileSync.returns(doubleQuoteIndexTsContent) | ||
| sinon.stub(bumpCommand, 'parse' as any).resolves({ | ||
| args: { path: testDir }, | ||
| flags: {} | ||
| }) | ||
|
|
||
| await bumpCommand.run() | ||
|
|
||
| const writtenContent = fsStubs.writeFileSync.firstCall.args[1] | ||
| expect(writtenContent).to.include('version: "1.0.1"') | ||
| }) | ||
| }) | ||
|
|
||
| describe('error cases', () => { | ||
| it('should fail when connector directory does not exist', async () => { | ||
| fsStubs.existsSync.withArgs(testDir).returns(false) | ||
| sinon.stub(bumpCommand, 'parse' as any).resolves({ | ||
| args: { path: testDir }, | ||
| flags: {} | ||
| }) | ||
|
|
||
| try { | ||
| await bumpCommand.run() | ||
| expect.fail('Should have thrown an error') | ||
| } catch (error: any) { | ||
| expect(error.message).to.match(/Directory .* does not exist/) | ||
| } | ||
| }) | ||
|
|
||
| it('should fail when src/index.ts does not exist', async () => { | ||
| fsStubs.existsSync.callsFake((path: fs.PathLike) => { | ||
| const pathStr = String(path) | ||
| // Connector directory exists, but index.ts does not | ||
| if (pathStr.includes('index.ts')) return false | ||
| return true | ||
| }) | ||
| sinon.stub(bumpCommand, 'parse' as any).resolves({ | ||
| args: { path: testDir }, | ||
| flags: {} | ||
| }) | ||
|
|
||
| try { | ||
| await bumpCommand.run() | ||
| expect.fail('Should have thrown an error') | ||
| } catch (error: any) { | ||
| expect(error.message).to.match(/Could not find src\/index\.ts/) | ||
| } | ||
| }) | ||
|
|
||
| it('should fail when version field is missing', async () => { | ||
| fsStubs.existsSync.returns(true) | ||
| fsStubs.readFileSync.returns(missingVersionIndexTsContent) | ||
| sinon.stub(bumpCommand, 'parse' as any).resolves({ | ||
| args: { path: testDir }, | ||
| flags: {} | ||
| }) | ||
|
|
||
| try { | ||
| await bumpCommand.run() | ||
| expect.fail('Should have thrown an error') | ||
| } catch (error: any) { | ||
| expect(error.message).to.match(/Could not find version field/) | ||
| } | ||
| }) | ||
|
|
||
| it('should fail when version is not valid semver', async () => { | ||
| fsStubs.existsSync.returns(true) | ||
| fsStubs.readFileSync.returns(invalidVersionIndexTsContent) | ||
| sinon.stub(bumpCommand, 'parse' as any).resolves({ | ||
| args: { path: testDir }, | ||
| flags: {} | ||
| }) | ||
|
|
||
| try { | ||
| await bumpCommand.run() | ||
| expect.fail('Should have thrown an error') | ||
| } catch (error: any) { | ||
| expect(error.message).to.match(/not a valid semantic version/) | ||
| } | ||
| }) | ||
| }) | ||
|
|
||
| describe('with current directory default', () => { | ||
| it('should use current directory when no path provided', async () => { | ||
| fsStubs.existsSync.returns(true) | ||
| fsStubs.readFileSync.returns(validIndexTsContent) | ||
| sinon.stub(bumpCommand, 'parse' as any).resolves({ | ||
| args: {}, | ||
| flags: {} | ||
| }) | ||
|
|
||
| await bumpCommand.run() | ||
|
|
||
| expect(fsStubs.readFileSync).to.have.been.called | ||
| expect(fsStubs.writeFileSync).to.have.been.called | ||
| }) | ||
| }) | ||
| }) |
Uh oh!
There was an error while loading. Please reload this page.