diff --git a/packages/zcli-connectors/src/commands/connectors/bump.ts b/packages/zcli-connectors/src/commands/connectors/bump.ts new file mode 100644 index 00000000..f1c1abd9 --- /dev/null +++ b/packages/zcli-connectors/src/commands/connectors/bump.ts @@ -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' }) + } + + async run (): Promise { + 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.') + } + + 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)) + } + } +} diff --git a/packages/zcli-connectors/src/commands/connectors/create.ts b/packages/zcli-connectors/src/commands/connectors/create.ts index 6cc53087..25c76097 100644 --- a/packages/zcli-connectors/src/commands/connectors/create.ts +++ b/packages/zcli-connectors/src/commands/connectors/create.ts @@ -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'" }) this.log(`✅ Connector '${connector}' created successfully!`) diff --git a/packages/zcli-connectors/src/templates/starter/src/index.ts b/packages/zcli-connectors/src/templates/starter/src/index.ts index 91f93217..37f6e3ac 100644 --- a/packages/zcli-connectors/src/templates/starter/src/index.ts +++ b/packages/zcli-connectors/src/templates/starter/src/index.ts @@ -7,6 +7,7 @@ const connector = manifest({ title: 'Starter Connector', description: 'Starter Connector', author: 'starter-author', + version: '0.0.1', authentication: authConfig, actions: [testAction], }); diff --git a/packages/zcli-connectors/tests/functional/bump.test.ts b/packages/zcli-connectors/tests/functional/bump.test.ts new file mode 100644 index 00000000..f6263ab2 --- /dev/null +++ b/packages/zcli-connectors/tests/functional/bump.test.ts @@ -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 + }) + }) +})