Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions packages/zcli-connectors/src/commands/connectors/bump.ts
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' })
}

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.')
}

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
Expand Up @@ -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
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This replacement is a no-op ("version: '0.0.1'" → identical string) and doesn't change the generated connector. Consider removing it, or (if you intend to set an initial version) replace it with a meaningful value derived from inputs.

Suggested change
"description: 'Starter Connector'": `description: '${toTitleCase(connector)} connector'`,
"version: '0.0.1'": "version: '0.0.1'"
})
"description: 'Starter Connector'": `description: '${toTitleCase(connector)} connector'`
})

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

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.

this.log(`✅ Connector '${connector}' created successfully!`)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const connector = manifest({
title: 'Starter Connector',
description: 'Starter Connector',
author: 'starter-author',
version: '0.0.1',
authentication: authConfig,
actions: [testAction],
});
Expand Down
243 changes: 243 additions & 0 deletions packages/zcli-connectors/tests/functional/bump.test.ts
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
})
})
})
Loading