diff --git a/messages/deploy.md b/messages/deploy.md index 651d29d..5bfaccb 100644 --- a/messages/deploy.md +++ b/messages/deploy.md @@ -159,3 +159,11 @@ Function invocation option (function packages only). # flags.functionInvokeOpt.description Configuration for how functions should be invoked. UnstructuredChunking is only valid option at this point + +# error.flagEmpty + +The --%s flag requires a non-empty value. + +# error.flagTooLong + +The --%s flag value exceeds the maximum length of %s characters (%s provided). diff --git a/messages/pipChecker.md b/messages/pipChecker.md index 5261b8f..f791fa0 100644 --- a/messages/pipChecker.md +++ b/messages/pipChecker.md @@ -1,3 +1,13 @@ +# warn.updateAvailable + +A newer version of '%s' is available: %s (you have %s). + +# actions.updatePackage + +- Upgrade using pip: pip install --upgrade salesforce-data-customcode +- Or upgrade using pip3: pip3 install --upgrade salesforce-data-customcode +- Or upgrade using python3: python3 -m pip install --upgrade salesforce-data-customcode + # error.pipNotFound Pip is not installed or not accessible in your system PATH. @@ -23,4 +33,4 @@ Required package '%s' is not installed. - Consider using a virtual environment for better dependency management - On macOS/Linux: python3 -m venv venv && source venv/bin/activate && pip install salesforce-data-customcode - On Windows: python -m venv venv && venv\Scripts\activate && pip install salesforce-data-customcode -- Verify installation by running: pip show salesforce-data-customcode \ No newline at end of file +- Verify installation by running: pip show salesforce-data-customcode diff --git a/src/base/deployBase.ts b/src/base/deployBase.ts index 2bcfdf7..78b99bd 100644 --- a/src/base/deployBase.ts +++ b/src/base/deployBase.ts @@ -14,7 +14,7 @@ * limitations under the License. */ import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; -import { Messages, Org } from '@salesforce/core'; +import { Messages, Org, SfError } from '@salesforce/core'; import { DatacodeBinaryExecutor, type DatacodeDeployExecutionResult } from '../utils/datacodeBinaryExecutor.js'; import { checkEnvironment } from '../utils/environmentChecker.js'; import { type SharedResultProps } from './types.js'; @@ -50,17 +50,46 @@ export abstract class DeployBase { + if (input.length === 0) throw new SfError(messages.getMessage('error.flagEmpty', ['name']), 'InvalidFlagValue'); + if (input.length > 64) + throw new SfError( + messages.getMessage('error.flagTooLong', ['name', '64', input.length.toString()]), + 'InvalidFlagValue' + ); + return Promise.resolve(input); + }, }), 'package-version': Flags.string({ summary: messages.getMessage('flags.packageVersion.summary'), description: messages.getMessage('flags.packageVersion.description'), required: true, + parse: (input) => { + if (input.length === 0) + throw new SfError(messages.getMessage('error.flagEmpty', ['package-version']), 'InvalidFlagValue'); + if (input.length > 64) + throw new SfError( + messages.getMessage('error.flagTooLong', ['package-version', '64', input.length.toString()]), + 'InvalidFlagValue' + ); + return Promise.resolve(input); + }, }), description: Flags.string({ char: 'd', summary: messages.getMessage('flags.description.summary'), description: messages.getMessage('flags.description.description'), required: true, + parse: (input) => { + if (input.length === 0) + throw new SfError(messages.getMessage('error.flagEmpty', ['description']), 'InvalidFlagValue'); + if (input.length > 255) + throw new SfError( + messages.getMessage('error.flagTooLong', ['description', '255', input.length.toString()]), + 'InvalidFlagValue' + ); + return Promise.resolve(input); + }, }), network: Flags.string({ summary: messages.getMessage('flags.network.summary'), @@ -103,6 +132,10 @@ export abstract class DeployBase { + try { + const latestVersion = await this.fetchLatestPyPIVersion(packageInfo.name); + + if (!latestVersion || !this.isNewerVersion(packageInfo.version, latestVersion)) { + return null; + } + + return { + packageName: packageInfo.name, + installedVersion: packageInfo.version, + latestVersion, + pipCommand: packageInfo.pipCommand, + }; + } catch { + // Network errors, timeouts, parse failures — silently ignore + return null; + } + } + + private static fetchLatestPyPIVersion(packageName: string): Promise { + return new Promise((resolve) => { + const req = get(`https://pypi.org/pypi/${encodeURIComponent(packageName)}/json`, { timeout: 5000 }, (res) => { + if (res.statusCode !== 200) { + res.resume(); + resolve(null); + return; + } + let body = ''; + res.setEncoding('utf8'); + res.on('data', (chunk: string) => { + body += chunk; + }); + res.on('end', () => { + try { + const data = JSON.parse(body) as { info?: { version?: string } }; + resolve(data?.info?.version ?? null); + } catch { + resolve(null); + } + }); + }); + req.on('timeout', () => { + req.destroy(); + resolve(null); + }); + req.on('error', () => resolve(null)); + }); + } + + /** + * Returns true if latestVersion is strictly newer than installedVersion. + * Compares major.minor.patch numerically; pre-release suffixes are ignored. + */ + private static isNewerVersion(installedVersion: string, latestVersion: string): boolean { + const parse = (v: string): number[] => + v + .split('.') + .slice(0, 3) + .map((part) => parseInt(part, 10) || 0); + + const installed = parse(installedVersion); + const latest = parse(latestVersion); + + for (let i = 0; i < 3; i++) { + const ins = installed[i] ?? 0; + const lat = latest[i] ?? 0; + if (lat !== ins) return lat > ins; + } + return false; + } + /** * Gets the package information for a specific pip command and package name. * diff --git a/test/commands/data-code-extension/deploy.test.ts b/test/commands/data-code-extension/deploy.test.ts index 5b9a4ef..6d71259 100644 --- a/test/commands/data-code-extension/deploy.test.ts +++ b/test/commands/data-code-extension/deploy.test.ts @@ -511,4 +511,172 @@ describe('data-code-extension deploy', () => { } }); }); + + describe('flag validation', () => { + const baseFlags = [ + '--package-version', + '1.0.0', + '--description', + 'Test', + '--package-dir', + '', + '--target-org', + 'test@example.com', + ]; + + it('should reject an empty --name value', async () => { + try { + await ScriptDeploy.run(['--name', '', ...baseFlags]); + expect.fail('Should have thrown an error'); + } catch (error: unknown) { + expect(error).to.be.instanceOf(Error); + } + }); + + it('should reject --name longer than 64 characters', async () => { + try { + await ScriptDeploy.run(['--name', 'a'.repeat(65), ...baseFlags]); + expect.fail('Should have thrown an error'); + } catch (error: unknown) { + expect(error).to.be.instanceOf(SfError); + expect((error as SfError).name).to.equal('InvalidFlagValue'); + } + }); + + it('should accept --name at the 64-character boundary', async () => { + // parse() throws for >64 but must not throw for exactly 64 + try { + await ScriptDeploy.run(['--name', 'a'.repeat(64), ...baseFlags]); + } catch (error: unknown) { + if (error instanceof SfError && (error as SfError).name === 'InvalidFlagValue') { + expect.fail('Should not have thrown InvalidFlagValue for a 64-char name'); + } + // Other errors (e.g. directory not found) are acceptable in this test + } + }); + + it('should reject an empty --package-version value', async () => { + try { + await ScriptDeploy.run([ + '--name', + 'test', + '--package-version', + '', + '--description', + 'Test', + '--package-dir', + testDir, + '--target-org', + 'test@example.com', + ]); + expect.fail('Should have thrown an error'); + } catch (error: unknown) { + expect(error).to.be.instanceOf(Error); + } + }); + + it('should reject --package-version longer than 64 characters', async () => { + try { + await ScriptDeploy.run([ + '--name', + 'test', + '--package-version', + 'a'.repeat(65), + '--description', + 'Test', + '--package-dir', + testDir, + '--target-org', + 'test@example.com', + ]); + expect.fail('Should have thrown an error'); + } catch (error: unknown) { + expect(error).to.be.instanceOf(SfError); + expect((error as SfError).name).to.equal('InvalidFlagValue'); + } + }); + + it('should accept --package-version at the 64-character boundary', async () => { + try { + await ScriptDeploy.run([ + '--name', + 'test', + '--package-version', + 'a'.repeat(64), + '--description', + 'Test', + '--package-dir', + testDir, + '--target-org', + 'test@example.com', + ]); + } catch (error: unknown) { + if (error instanceof SfError && (error as SfError).name === 'InvalidFlagValue') { + expect.fail('Should not have thrown InvalidFlagValue for a 64-char package-version'); + } + } + }); + + it('should reject an empty --description value', async () => { + try { + await ScriptDeploy.run([ + '--name', + 'test', + '--package-version', + '1.0.0', + '--description', + '', + '--package-dir', + testDir, + '--target-org', + 'test@example.com', + ]); + expect.fail('Should have thrown an error'); + } catch (error: unknown) { + expect(error).to.be.instanceOf(Error); + } + }); + + it('should reject --description longer than 255 characters', async () => { + try { + await ScriptDeploy.run([ + '--name', + 'test', + '--package-version', + '1.0.0', + '--description', + 'a'.repeat(256), + '--package-dir', + testDir, + '--target-org', + 'test@example.com', + ]); + expect.fail('Should have thrown an error'); + } catch (error: unknown) { + expect(error).to.be.instanceOf(SfError); + expect((error as SfError).name).to.equal('InvalidFlagValue'); + } + }); + + it('should accept --description at the 255-character boundary', async () => { + try { + await ScriptDeploy.run([ + '--name', + 'test', + '--package-version', + '1.0.0', + '--description', + 'a'.repeat(255), + '--package-dir', + testDir, + '--target-org', + 'test@example.com', + ]); + } catch (error: unknown) { + if (error instanceof SfError && (error as SfError).name === 'InvalidFlagValue') { + expect.fail('Should not have thrown InvalidFlagValue for a 255-char description'); + } + } + }); + }); }); diff --git a/test/utils/pipChecker.test.ts b/test/utils/pipChecker.test.ts index e5c934a..c0e0c53 100644 --- a/test/utils/pipChecker.test.ts +++ b/test/utils/pipChecker.test.ts @@ -14,9 +14,17 @@ * limitations under the License. */ import { expect } from 'chai'; +import type * as Sinon from 'sinon'; import { TestContext } from '@salesforce/core/testSetup'; import { SfError } from '@salesforce/core'; -import { PipChecker } from '../../src/utils/pipChecker.js'; +import { PipChecker, type PipPackageInfo } from '../../src/utils/pipChecker.js'; + +const INSTALLED_PACKAGE: PipPackageInfo = { + name: 'salesforce-data-customcode', + version: '0.1.28', + location: '/usr/local/lib/python3.11/site-packages', + pipCommand: 'pip3', +}; describe('PipChecker', () => { const $$ = new TestContext(); @@ -25,6 +33,77 @@ describe('PipChecker', () => { $$.restore(); }); + describe('checkForUpdate', () => { + let pypiStub: Sinon.SinonStub; + + beforeEach(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + pypiStub = $$.SANDBOX.stub(PipChecker as any, 'fetchLatestPyPIVersion'); + }); + + it('should return PipUpdateInfo when a newer version is available', async () => { + pypiStub.resolves('0.2.0'); + + const result = await PipChecker.checkForUpdate(INSTALLED_PACKAGE); + + expect(result).to.not.be.null; + expect(result?.packageName).to.equal('salesforce-data-customcode'); + expect(result?.installedVersion).to.equal('0.1.28'); + expect(result?.latestVersion).to.equal('0.2.0'); + expect(result?.pipCommand).to.equal('pip3'); + }); + + it('should return null when already on the latest version', async () => { + pypiStub.resolves('0.1.28'); + + const result = await PipChecker.checkForUpdate(INSTALLED_PACKAGE); + + expect(result).to.be.null; + }); + + it('should return null when installed version is newer than PyPI (e.g. pre-release)', async () => { + pypiStub.resolves('0.1.0'); + + const result = await PipChecker.checkForUpdate(INSTALLED_PACKAGE); + + expect(result).to.be.null; + }); + + it('should return null when the PyPI lookup returns null', async () => { + pypiStub.resolves(null); + + const result = await PipChecker.checkForUpdate(INSTALLED_PACKAGE); + + expect(result).to.be.null; + }); + + it('should return null when the PyPI lookup throws', async () => { + pypiStub.rejects(new Error('network error')); + + const result = await PipChecker.checkForUpdate(INSTALLED_PACKAGE); + + expect(result).to.be.null; + }); + + it('should detect a newer patch version', async () => { + pypiStub.resolves('0.1.29'); + + const result = await PipChecker.checkForUpdate(INSTALLED_PACKAGE); + + expect(result).to.not.be.null; + expect(result?.latestVersion).to.equal('0.1.29'); + }); + + it('should detect a newer major version', async () => { + pypiStub.resolves('1.0.0'); + + const result = await PipChecker.checkForUpdate(INSTALLED_PACKAGE); + + expect(result).to.not.be.null; + expect(result?.latestVersion).to.equal('1.0.0'); + }); + }); + describe('checkPackage', () => { it('should detect commonly installed packages', async () => { // Test with a package that's likely to be installed in dev environments