Plugins
SFDT plugins extend the CLI with new subcommands using Commander.js. A plugin is a Node.js ESM
module that exports a single register function.
The contract
/**
* @param {import('commander').Command} program
*/
export function register(program) {
program
.command('my-command')
.description('What this command does')
.option('--flag <value>', 'Description')
.action(async (options) => {
// implementation
});
}The program argument is the root Commander instance. Call .command() to add subcommands.
Do not call program.parse() or program.parseAsync() — the CLI entry point handles that
after all plugins load.
Loading sources & precedence
Plugins load in this order, before CLI argument parsing:
1. Explicit packages (config.plugins[])
{ "plugins": ["sfdt-plugin-my-thing", "@myorg/sfdt-plugin-deploy-ext"] }Resolved from the project’s node_modules/, not the global sfdt install. Explicit plugins
that fail to load print a warning but never abort startup.
Since 0.22.0, plugins[] requires an opt-in.
export SFDT_ALLOW_UNSAFE_CONFIG=1Without it the entries are refused and sfdt prints what it skipped. .sfdt/config.json is
normally committed, so it arrives with whatever repository you cloned — and these entries are
import()ed at startup, before argument parsing, so any sfdt command at all (including
sfdt --version) would run that code.
The opt-in is an environment variable rather than a config key on purpose: a key inside
config.json would be set by whoever wrote that file. Put the export in your shell profile or
the CI job that needs the plugin — never in the repo’s own config.
This supersedes the 0.20.0 rule. 0.20.0 rejected path-shaped specifiers (anything starting
with ., /, or ~, containing a \ or a Windows drive letter, or containing a ..
segment). That rule still applies — package specifiers only, though scoped names and package
subpaths remain fine — but on its own it did not close the hole: a plain package name still
resolved out of the cloned repository’s own node_modules/, so a repo could vendor its payload
and skip the path check entirely. The 0.22.0 opt-in is what actually closes it.
2. Auto-discovered packages (pluginOptions.autoDiscover)
{ "pluginOptions": { "autoDiscover": true } }When enabled, sfdt scans node_modules/ for packages named sfdt-plugin-* (including scoped
@org/sfdt-plugin-*) and loads them automatically.
Auto-discovery is off by default — it executes arbitrary project-local code before CLI parsing. Only enable it when you control the packages in your project.
Since 0.22.0 it also requires SFDT_ALLOW_UNSAFE_CONFIG=1, for the same reason as
plugins[] above and covering both of its sources: every sfdt-plugin-* in node_modules/
and every file in .sfdt/plugins/. Setting autoDiscover: true in a committed
config.json is not sufficient on its own — otherwise a cloned repo could flip one boolean and
ship its own node_modules/sfdt-plugin-*.
3. Local files (.sfdt/plugins/*.js)
Also requires pluginOptions.autoDiscover: true. Any .js/.mjs file in .sfdt/plugins/ is
loaded as a local plugin:
.sfdt/
plugins/
custom-deploy.js ← loaded automatically when autoDiscover is trueScaffold a plugin
sfdt plugin create my-plugin --description "My custom command" --author "you@example.com"Minimal example
// sfdt-plugin-hello/index.js
export function register(program) {
program
.command('hello')
.description('Print a greeting')
.option('--name <name>', 'Who to greet', 'world')
.action((options) => {
console.log(`Hello, ${options.name}!`);
});
}Install it in your Salesforce project and list it:
npm install --save-dev sfdt-plugin-hello{ "plugins": ["sfdt-plugin-hello"] }Error handling
Plugin failures are warnings, never crashes. If a plugin throws during load, sfdt prints a warning and continues — the CLI stays fully functional.
Naming rules
- npm packages must start with
sfdt-plugin-for auto-discovery. - Scoped
@yourorg/sfdt-plugin-namealso qualifies for auto-discovery. - Any name works when listed explicitly in
config.plugins[].