3dx is a community-built third-party developer experience for Salesforce CLI, providing a modern sf plugin template — and, optionally, a matching GitHub Action wrapper — built on npm, Vitest, and Biome instead of the legacy-leaning toolchain in Salesforce's official plugin template.
Table of Contents
Salesforce's official template is the standard starting point for a new sf plugin, but its dependency stack has drifted from the latest the Node.js ecosystem has to offer:
| Concern | plugin-template |
3dx |
|---|---|---|
| Package manager | Yarn | npm |
| Test runner | Mocha + Chai | Vitest |
| Coverage | nyc (Istanbul CLI) | Vitest's built-in v8 coverage + upload to Codecov |
| Lint + format | ESLint + Prettier | Biome (single tool, one config) + MegaLinter on PRs |
| Mutation testing | none | Stryker (optional, incremental on PRs) |
| Task orchestration | mix of @salesforce/dev-scripts, wireit, and scripts | Wireit (caching, incremental builds) |
| Commit hygiene | none built in (husky and commitlint files present but missing husky dev dep) | Husky + commitlint + lint-staged |
| Releases | manually triggered workflow | release-please + npm Trusted Publishing (OIDC) |
| GitHub Action | none | native node Action, sharing logic with the CLI command — see GitHub Action |
None of this changes what a plugin is — it's still an oclif command tree using @oclif/core and @salesforce/sf-plugins-core, same as the official template. What changes is everything around it: faster installs, one linter instead of two, tests that don't need a compile step first, and CI that mirrors what you'd actually run locally.
- Runtime deps:
@oclif/core,@salesforce/core,@salesforce/sf-plugins-core— the same three packages anysfplugin needs. - Build: TypeScript (strict, ESM, Node16 resolution), orchestrated by Wireit so
npm run buildonly redoes work when inputs changed. - Lint/format: Biome, one config (
biome.json), no ESLint/Prettier split to keep in sync. - Tests: Vitest for unit tests (
test/**/*.test.ts) with coverage thresholds, and@salesforce/cli-plugins-testkitfor NUTs (test/**/*.nut.ts) that exercise the compiled plugin through a realsfprocess. - Mutation testing: Stryker, wired to run incrementally against only the files a PR changed (
scripts/incremental-mutation.mjs), with an optional full run and dashboard upload viaworkflow_dispatch. - Git hooks: Husky, via three hooks:
- commit:
lint-stagedruns Biome against staged files only. - commit-msg: commitlint enforces Conventional Commits.
- pre-push:
npm run readme(a full build, via wireit's dependency graph, thenoclif readme) — the push is blocked if regenerating the README's Command Reference produces a diff, so it never drifts from the actual commands.
- commit:
- Dependency hygiene:
knipflags unused exports/files/deps;ls-engineschecks the dependency tree against theengines.nodefloor. - CI: GitHub Actions for lint + unit tests + NUTs across OS/Node matrices, MegaLinter on PRs, incremental Stryker on PRs, and a release pipeline (release-please → npm publish via OIDC → post-publish smoke test).
- GitHub Action: the example command is also wrapped as a committed, bundled
action.ymlso consumers canuses: <owner>/<repo>@v1in their own workflows without installing the plugin — see GitHub Action.
-
Click Use this template on GitHub (or
gh repo create my-plugin --template mcarvin8/3dx --public), then clone it. -
Run
npm installfollowed bynpm run initto rename the plugin. It prompts for the new npm package name, oclif topic,package.jsondescription, and GitHubowner/repo(guessed fromgit remote originif one is set), then:- renames
src/commands/3dx/andtest/commands/3dx/to your topic - renames
messages/3dx.hello.mdand updates theMessages.loadMessages('3dx', ...)call in each command - rewrites
package.json(name,description,oclif.topics,repository.url,bugs.url) and thesf plugins install --force 3dx@latestline in.github/workflows/smoke-test.yml - resets the version to
0.0.1, clearsCHANGELOG.md, and deletes itself when done
For a non-interactive run, pass flags instead:
npm run init -- --name=@myorg/my-plugin --topic=my-plugin --description="..." --repo=myorg/my-plugin --yes. - renames
-
Replace the example
hellocommand with your first real command (see Adding a command), or delete it if you're starting from a bare skeleton. -
npm installagain to refreshpackage-lock.jsonunder the new name, thennpm run build && npm testto confirm everything still passes. -
If you want npm Trusted Publishing (no
NPM_TOKENsecret), register this repo + therelease.ymlworkflow as a trusted publisher athttps://www.npmjs.com/package/<name>/accessbefore your first tagged release. Otherwise, see the comment inrelease.ymlfor the classic-token alternative. -
Grant the
GITHUB_TOKENwrite access. In repo Settings → Actions → General → Workflow permissions, select Read and write permissions and check Allow GitHub Actions to create and approve pull requests. Both are required:release-please(release.yml) needs write access to open/update the release PR and push the release commit.- MegaLinter's auto-fix step (
megalinter.yml,git-auto-commit-action) needs write access to push formatting fixes back to PR branches.
Without this, both fail with a 403 (
release-pleaseerrors on PR creation; MegaLinter's commit step is silently skipped). -
Add repo secrets under Settings → Secrets and variables → Actions → New repository secret:
CODECOV_TOKEN— required for the coverage upload step intest.yml/release.ymlto succeed.- Add this repo at codecov.io first, then copy the token from the repo's Codecov settings.
- Scaffolding several plugins from this template? Use your global upload token instead (Codecov org settings → Global Upload Token) — one token works across all your repos, so there's no per-repo token to hunt down each time.
test.ymlalso runs on Dependabot's PRs (e.g. the dev-dependencies bump), but Dependabot-triggered runs can't see regular Actions secrets — GitHub withholds them unless the secret is also added under Settings → Secrets and variables → Dependabot → New repository secret. AddCODECOV_TOKENthere too (same value, global or per-repo), or coverage upload silently fails on every Dependabot PR.
TESTKIT_*(TESTKIT_AUTH_URL,TESTKIT_HUB_USERNAME,TESTKIT_JWT_CLIENT_ID,TESTKIT_JWT_KEY,TESTKIT_HUB_INSTANCE) — optional, only needed if you point NUTs at a real Dev Hub instead ofdevhubAuthStrategy: 'NONE'.STRYKER_DASHBOARD_API_KEY— optional, only needed for the full mutation run's dashboard upload (workflow_dispatch).
src/
commands/3dx/hello.ts # one command = one file; thin wrapper over src/core
core/hello.ts # the actual logic — a plain function, no oclif/Action deps
action/{index,main}.ts # GitHub Action entrypoint, wraps src/core the same way
index.ts # oclif plugin entry point (leave as `export default {}`)
messages/
3dx.hello.md # summary/description/examples/flag text, loaded via Messages
test/
commands/3dx/hello.test.ts # unit test — imports the command class directly
commands/3dx/hello.nut.ts # NUT — drives the compiled plugin through execCmd
core/hello.test.ts # unit test — imports the pure logic function directly
action/main.test.ts # unit test — mocks @actions/core and src/core
action.yml # GitHub Action manifest, see GitHub Action
dist/action/index.cjs # committed esbuild bundle the Action manifest points at
Command, message file, unit test, and NUT are named to mirror each other 1:1 — that mapping is what knip.config.ts and the Stryker mutate excludes assume. src/core/hello.ts is what makes the command and the Action reusable from one place — see GitHub Action.
- Add
messages/<topic>.<command>.mdwith# summary,# description,# examples, and# flags.<name>.summarysections per flag. - Add
src/commands/<topic>/<command>.tsextendingSfCommand<YourResultType>, loading the message file viaMessages.loadMessages('<package-name>', '<topic>.<command>'). - Add a unit test under
test/commands/<topic>/<command>.test.tsthat callsYourCommand.run([...])directly and asserts on the returned result. - Add a NUT under
test/commands/<topic>/<command>.nut.tsusingexecCmdfrom@salesforce/cli-plugins-testkit. - Run
npm run readmeto regenerate the Command Reference from your command's flags and message file, then commit the updatedREADME.md. - Want this command exposed as a GitHub Action too? Extract its logic into
src/core/<command>.tsas a plain function (that's already howhellois wired), then have both the command class andsrc/action/main.tscall it. The template only wires the Action to one command at a time — exposing more than one is a per-plugin decision (either branch on an Action input, or ship additionalaction.ymlfiles in subdirectories).
Print a greeting. Reference command demonstrating this template's conventions: oclif command class, @salesforce/core Messages for i18n, flags, and a typed JSON result.
USAGE
$ sf 3dx hello [--json] [--flags-dir <value>] [-n <value>]
FLAGS
-n, --name=<value> [default: World] Name to greet. Defaults to "World".
GLOBAL FLAGS
--flags-dir=<value> Import flag values from a directory.
--json Format output as json.
DESCRIPTION
Print a greeting. Reference command demonstrating this template's conventions: oclif command class, `@salesforce/core`
Messages for i18n, flags, and a typed JSON result.
Print a greeting. This is the one example command shipped with the 3dx template — replace it with your plugin's first
real command, or use it as a reference for the file layout (command class, message file, unit test, NUT) when adding
new ones.
EXAMPLES
`sf 3dx hello`
`sf 3dx hello --name "World"`
See code: src/commands/3dx/hello.ts
| Command | Does |
|---|---|
npm run build |
Compile + lint + Action bundle |
npm run build:action |
Bundle src/action into dist/action/index.cjs |
npm test |
Compile tests + unit tests (with coverage) + lint |
npm run test:only |
Unit tests + coverage, no lint/compile |
npm run test:nuts |
NUTs against the built plugin |
npm run test:mutation |
Full Stryker run |
npm run test:mutation:incremental |
Stryker against files changed vs. the base branch |
npm run lint / format |
Biome check / write |
npm run lint:dependencies |
knip — unused files/exports/deps |
npm run lint:engine |
ls-engines — dependency tree vs. engines.node |
npm run clean |
Remove build/test artifacts |
test.yml— runs on every push to a non-main branch and viaworkflow_call: lint (including a check thatdist/actionis rebuilt and committed), unit tests + coverage (matrix across ubuntu/windows/macos × Node 22/24/26), then NUTs on the same matrix.release.yml— on push tomain: rebuildsdist/actionif stale; release-please opens/updates a release PR; when a release is published, it publishes to npm (OIDC Trusted Publishing), moves the floatingvNtag consumers pin the Action to, and triggers the smoke test.smoke-test.yml— installs the just-published plugin into a realsfCLI and reruns the NUT suite against it, cross-OS.smoke-test-action.yml— exercises the GitHub Action itself:./(the checkout about to merge) on PRs touching Action files,@vN(the live published tag) on manualworkflow_dispatch.megalinter.yml— broad-spectrum linting on PRs (secrets, Dockerfiles, shell scripts, YAML, etc. — Biome already owns JS/TS, so those linters are disabled in.mega-linter.ymlto avoid overlap).mutation.yml— incremental Stryker on PRs (scoped to changed files, posts a PR comment); full run + optional dashboard upload via manualworkflow_dispatch. Both jobsnpm install typescript@6.0.3 --no-saveright afternpm run compile— Stryker's TypeScript checker plugin doesn't yet support TS 7 (only "experimental support" as of Stryker 10), so the repo's real TS 7 toolchain builds the plugin, then gets swapped for a TS 6 copy for just the mutation run.--no-savekeepspackage.json/the lockfile untouched, and the downgrade never leaves the job — runners are thrown away after each run.
The hello example is also shipped as a native GitHub Action (action.yml, runs.using: node24) — not a wrapper that shells out to sf plugins install. It imports the same src/core/hello.ts function the CLI command calls, and esbuild bundles it (plus @actions/core for typed inputs/outputs) into a single, git-committed dist/action/index.cjs. A consumer's workflow gets:
- uses: <owner>/<repo>@v1
with:
name: 'World'with no sf CLI install step, no plugin install, and typed outputs.message — just the Node runtime GitHub Actions already provides.
Why the bundle is committed, not built on demand: GitHub Actions runs a node24-type Action's main file straight from the git ref a consumer pins (@v1, a SHA, a branch) — there's no build step in between. dist/action/index.cjs has to already exist at that ref. test.yml's lint job re-runs npm run build:action and fails on any diff against the committed copy, so the bundle can't silently drift from src/action/src/core; release.yml's rebuild-dist job is the safety net that keeps main itself current.
Versioning: release.yml moves a floating vN tag (major version only) to every published release commit, so consumers pin @v1 rather than an exact patch — the same convention actions/checkout and most published Actions use.
Extending to more commands: the template wires exactly one command to the Action. See step 6 of Adding a command for the pattern (extract to src/core/) and its options for exposing more than one command.
Not shipping a GitHub Action? Delete action.yml, src/action/, test/action/, dist/action/, the build:action wireit task and its !/lib/action entry in package.json's files, smoke-test-action.yml, and the rebuild-dist job + floating-tag step in release.yml. The src/core/ split is still good practice to keep even without the Action — it's what makes the command's logic unit-testable without going through oclif's parser.
Everything here is meant to be deleted, not just configured. In particular:
- No plans to publish, or to publish infrequently? Delete
release.yml's smoke-test trigger and OIDC step; a plainnpm publishlocally is fine. - Not doing cross-platform NUTs? Collapse the
test.yml/smoke-test.ymlmatrices toubuntu-latestonly. - Mutation testing and MegaLinter are the two heaviest, most opinionated pieces — drop
mutation.yml,stryker.config.json,scripts/incremental-mutation.mjs,megalinter.yml, and.mega-linter.ymlif they're not earning their CI minutes for your plugin. - Not shipping a GitHub Action? See the last paragraph of GitHub Action for what to delete.