Skip to Content
CLIPlugins

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=1

Without 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 true

Scaffold 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-name also qualifies for auto-discovery.
  • Any name works when listed explicitly in config.plugins[].
Last updated on