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
8 changes: 8 additions & 0 deletions messages/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
12 changes: 11 additions & 1 deletion messages/pipChecker.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
- Verify installation by running: pip show salesforce-data-customcode
35 changes: 34 additions & 1 deletion src/base/deployBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -50,17 +50,46 @@ export abstract class DeployBase<TFlags extends BaseDeployFlags = BaseDeployFlag
summary: messages.getMessage('flags.name.summary'),
description: messages.getMessage('flags.name.description'),
required: true,
parse: (input) => {
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'),
Expand Down Expand Up @@ -103,6 +132,10 @@ export abstract class DeployBase<TFlags extends BaseDeployFlags = BaseDeployFlag

const additionalFlags = this.getAdditionalFlags(flags);

if (packageDir.length === 0) {
throw new SfError(messages.getMessage('error.flagEmpty', ['package-dir']), 'InvalidFlagValue');
}

try {
const { pythonInfo, packageInfo, binaryInfo } = await checkEnvironment(
this.spinner,
Expand Down
19 changes: 18 additions & 1 deletion src/utils/environmentChecker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
87 changes: 87 additions & 0 deletions src/utils/pipChecker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Expand Down Expand Up @@ -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<PipUpdateInfo | null> {
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<string | null> {
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.
*
Expand Down
Loading
Loading