From fc23bba49f7e55cb7ae47b8de7ab703b14b2a4ae Mon Sep 17 00:00:00 2001 From: Jesus Orosco Date: Mon, 13 Apr 2026 12:45:42 -0700 Subject: [PATCH 1/2] fix: adding character min/max checking on required flags --- messages/deploy.md | 8 + src/base/deployBase.ts | 35 +++- .../data-code-extension/deploy.test.ts | 168 ++++++++++++++++++ 3 files changed, 210 insertions(+), 1 deletion(-) 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/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 { } }); }); + + 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'); + } + } + }); + }); }); From 957d52dd831196a97974659da15a31f536391082 Mon Sep 17 00:00:00 2001 From: Jesus Orosco Date: Mon, 13 Apr 2026 12:46:41 -0700 Subject: [PATCH 2/2] fix: adding pip package update checker and user warning --- messages/pipChecker.md | 12 ++++- src/utils/environmentChecker.ts | 19 ++++++- src/utils/pipChecker.ts | 87 +++++++++++++++++++++++++++++++++ test/utils/pipChecker.test.ts | 81 +++++++++++++++++++++++++++++- 4 files changed, 196 insertions(+), 3 deletions(-) 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/utils/environmentChecker.ts b/src/utils/environmentChecker.ts index 7f42128..68cc1f6 100644 --- a/src/utils/environmentChecker.ts +++ b/src/utils/environmentChecker.ts @@ -41,11 +41,28 @@ export async function checkEnvironment( spinner.stop(); log(messages.getMessage('info.packageFound', [packageInfo.name, packageInfo.version])); + // Fire the update check now — it runs in parallel with the binary check so it doesn't add wait time. + const updateCheckPromise = PipChecker.checkForUpdate(packageInfo); + spinner.start(messages.getMessage('info.checkingBinary')); - const binaryInfo = await DatacodeBinaryChecker.checkBinary(); + const [binaryInfo, updateInfo] = await Promise.all([DatacodeBinaryChecker.checkBinary(), updateCheckPromise]); spinner.stop(); log(messages.getMessage('info.binaryFound', [binaryInfo.version])); + if (updateInfo) { + const pipMessages = Messages.loadMessages('@salesforce/plugin-data-code-extension', 'pipChecker'); + log( + pipMessages.getMessage('warn.updateAvailable', [ + updateInfo.packageName, + updateInfo.latestVersion, + updateInfo.installedVersion, + ]) + ); + for (const action of pipMessages.getMessages('actions.updatePackage')) { + log(` ${action}`); + } + } + return { pythonInfo, packageInfo, binaryInfo }; } diff --git a/src/utils/pipChecker.ts b/src/utils/pipChecker.ts index 29d82b6..7296386 100644 --- a/src/utils/pipChecker.ts +++ b/src/utils/pipChecker.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { get } from 'node:https'; import { SfError } from '@salesforce/core'; import { Messages } from '@salesforce/core'; import { spawnAsync } from './spawnHelper.js'; @@ -27,6 +28,13 @@ export type PipPackageInfo = { pipCommand: string; }; +export type PipUpdateInfo = { + packageName: string; + installedVersion: string; + latestVersion: string; + pipCommand: string; +}; + type PipCommand = { cmd: string; args: string[] }; export class PipChecker { @@ -75,6 +83,85 @@ export class PipChecker { ); } + /** + * Checks PyPI for a newer version of the given package. + * Returns PipUpdateInfo if a newer version is available, null if up-to-date or if the check fails. + * Never throws — update checks are best-effort and must not block the user. + * + * @param packageInfo The installed package info returned by checkPackage + */ + public static async checkForUpdate(packageInfo: PipPackageInfo): Promise { + 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/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