diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ecb68c0e..9eb6271d 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -14,7 +14,7 @@ Thanks for your interest in contributing to Simply! This document covers the rep
## Repository Structure
-This repository is a Lerna monorepo containing twelve Salesforce CLI plugins, plus four internal libraries. Every package has its own `CONTRIBUTING.md` covering what's specific to it — read this file first, then that one.
+This repository is a Lerna monorepo containing thirteen Salesforce CLI plugins, plus four internal libraries. Every package has its own `CONTRIBUTING.md` covering what's specific to it — read this file first, then that one.
| Package | Description | Bundled into `simply`? |
| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- |
@@ -25,6 +25,7 @@ This repository is a Lerna monorepo containing twelve Salesforce CLI plugins, pl
| [`@simplysf/simply-community`](packages/simply-community) | Salesforce Communities commands | ✅ |
| [`@simplysf/simply-data`](packages/simply-data) | File upload/download commands | ✅ |
| [`@simplysf/simply-document`](packages/simply-document) | Documentation generation commands | ✅ |
+| [`@simplysf/simply-flow`](packages/simply-flow) | Flow commands | ✅ |
| [`@simplysf/simply-package`](packages/simply-package) | Package dependency management commands | ✅ |
| [`@simplysf/simply-permissions`](packages/simply-permissions) | Permissions commands | ✅ |
| [`@simplysf/simply-project`](packages/simply-project) | Salesforce project commands | ✅ |
diff --git a/docs/design/0013-flow-and-permission-set-assignment-cleanup.md b/docs/design/0013-flow-and-permission-set-assignment-cleanup.md
index 9ca4aafb..9786e6bb 100644
--- a/docs/design/0013-flow-and-permission-set-assignment-cleanup.md
+++ b/docs/design/0013-flow-and-permission-set-assignment-cleanup.md
@@ -168,3 +168,30 @@ Parses a `.........` of a `` block matching `typeName` out of a `package.xml`/
+ * `destructiveChanges.xml`-shaped document.
+ *
+ * Both files share the same `` shape, and
+ * `fast-xml-parser` collapses a single `` or `` element to a bare object/string
+ * rather than a one-element array — normalized here the same way `customMetadataXml.ts`'s
+ * `extractValues` normalizes the analogous `` shape for `CustomMetadata` XML.
+ *
+ * @param xmlContent - The manifest file's raw XML text.
+ * @param typeName - The `` to look up (e.g. `'Flow'`, `'PermissionSet'`).
+ * @returns The matching type's members, or `[]` if `typeName` isn't present in the file at all —
+ * matching how a destructive-changes-driven caller treats "nothing of this type" as a no-op, not
+ * an error.
+ */
+export function readPackageManifestMembers(xmlContent: string, typeName: string): string[] {
+ const parsed = new XMLParser().parse(xmlContent) as RawPackageManifestXml;
+
+ const rawTypes = parsed.Package?.types;
+ const types = rawTypes === undefined ? [] : Array.isArray(rawTypes) ? rawTypes : [rawTypes];
+
+ const members: string[] = [];
+ for (const type of types) {
+ if (type.name !== typeName) {
+ continue;
+ }
+ if (type.members === undefined) {
+ continue;
+ }
+ members.push(...(Array.isArray(type.members) ? type.members : [type.members]));
+ }
+
+ return members;
+}
diff --git a/packages/simply-core/test/metadata/packageManifest.test.ts b/packages/simply-core/test/metadata/packageManifest.test.ts
new file mode 100644
index 00000000..05893f42
--- /dev/null
+++ b/packages/simply-core/test/metadata/packageManifest.test.ts
@@ -0,0 +1,50 @@
+/*
+ * Copyright (c) 2026, Clay Chipps.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { describe, expect, it } from 'vitest';
+import { readPackageManifestMembers } from '../../src/metadata/packageManifest.js';
+
+const XML_HEADER = '\n';
+
+describe('readPackageManifestMembers', () => {
+ it('normalizes a single block with a single element', () => {
+ const xml = `${XML_HEADER}\n \n My_Flow\n Flow\n \n 62.0\n\n`;
+
+ expect(readPackageManifestMembers(xml, 'Flow')).to.deep.equal(['My_Flow']);
+ });
+
+ it('normalizes multiple blocks, each with multiple ', () => {
+ const xml = `${XML_HEADER}\n \n My_Flow\n Another_Flow\n Flow\n \n \n My_Permission_Set\n Another_Permission_Set\n PermissionSet\n \n 62.0\n\n`;
+
+ expect(readPackageManifestMembers(xml, 'Flow')).to.deep.equal(['My_Flow', 'Another_Flow']);
+ expect(readPackageManifestMembers(xml, 'PermissionSet')).to.deep.equal([
+ 'My_Permission_Set',
+ 'Another_Permission_Set',
+ ]);
+ });
+
+ it('returns an empty array when the requested type is not present', () => {
+ const xml = `${XML_HEADER}\n \n My_Flow\n Flow\n \n 62.0\n\n`;
+
+ expect(readPackageManifestMembers(xml, 'PermissionSetGroup')).to.deep.equal([]);
+ });
+
+ it('returns an empty array for a Package with no at all', () => {
+ const xml = `${XML_HEADER}\n 62.0\n\n`;
+
+ expect(readPackageManifestMembers(xml, 'Flow')).to.deep.equal([]);
+ });
+});
diff --git a/packages/simply-flow/.gitignore b/packages/simply-flow/.gitignore
new file mode 100644
index 00000000..ff427f6b
--- /dev/null
+++ b/packages/simply-flow/.gitignore
@@ -0,0 +1,51 @@
+# -- CLEAN
+tmp/
+# use yarn by default, so ignore npm
+package-lock.json
+
+# never checkin npm config
+.npmrc
+
+# debug logs
+npm-error.log
+yarn-error.log
+
+# history extension
+.history
+
+# compile source
+lib
+
+# test artifacts
+*xunit.xml
+*checkstyle.xml
+*unitcoverage
+.nyc_output
+coverage
+test_session*
+
+# generated docs
+docs
+
+# ignore sfdx-trust files
+*.tgz
+*.sig
+package.json.bak.
+
+
+npm-shrinkwrap.json
+oclif.manifest.json
+oclif.lock
+
+# -- CLEAN ALL
+*.tsbuildinfo
+.eslintcache
+.wireit
+node_modules
+
+# --
+# put files here you don't want cleaned with sf-clean
+
+# os specific files
+.DS_Store
+.idea
diff --git a/packages/simply-flow/CONTRIBUTING.md b/packages/simply-flow/CONTRIBUTING.md
new file mode 100644
index 00000000..925084d6
--- /dev/null
+++ b/packages/simply-flow/CONTRIBUTING.md
@@ -0,0 +1,61 @@
+# Contributing to @simplysf/simply-flow
+
+Utilities for working with Flows. This package is part of the [`simply-node`](https://github.com/SimplySF/simply-node) monorepo.
+
+**Start with the [root CONTRIBUTING.md](https://github.com/SimplySF/simply-node/blob/main/CONTRIBUTING.md).** It covers repository structure, environment setup, commit conventions, versioning and publishing, CI, git hooks, and the pull request process — all of which apply here. This file covers only what is specific to this package.
+
+## Working on this package
+
+Run from this directory to target just this package:
+
+```sh
+pnpm run build # compile + lint + regenerate command-snapshot.json
+pnpm test # the full gate CI runs
+pnpm run test:only # just the unit tests, skipping lint and the doc gates
+pnpm run lint
+```
+
+## Trying a command locally
+
+Run this package's dev binary without installing it into the Salesforce CLI:
+
+```sh
+./bin/dev.js --help # macOS/Linux
+./bin/dev.cmd --help # Windows
+```
+
+Or link it so `sf` picks it up from anywhere:
+
+```sh
+sf plugins link .
+```
+
+## Command help text
+
+Summaries, descriptions, examples, and error messages live in [`messages/`](messages), not in the command source. Edit the relevant `messages/*.md` file, then regenerate the README command reference:
+
+```sh
+pnpm run readme
+```
+
+Commit the regenerated `README.md`. The docs site derives its command reference pages from it, so a stale README means stale published docs.
+
+## Command snapshot
+
+`command-snapshot.json` records every command and flag so that accidental breaking changes surface in review. It regenerates as part of `pnpm run build` — commit whatever changes. CI re-verifies with `git diff --exit-code`, so a stale snapshot fails the build.
+
+> **If you add, remove, or rename a flag here, also rebuild [`packages/simply`](https://github.com/SimplySF/simply-node/tree/main/packages/simply)'s snapshot.** `@simplysf/simply-flow` is bundled into the orchestrator plugin, so its aggregated snapshot carries these flags too. The orchestrator's wireit cache only watches `packages/simply/src/**/*.ts`, so a plain `pnpm run build` there reports cached success without regenerating anything — the drift only surfaces in CI. Force it:
+
+> ```sh
+> cd ../simply
+> node --loader ts-node/esm --no-warnings=ExperimentalWarning ./bin/dev.js snapshot:generate
+> npx prettier --write command-snapshot.json
+> ```
+
+## Tests
+
+No pull request is accepted without tests covering the change. Tests live in [`test/`](test), mirroring the `src/` layout, and run under [Vitest](https://vitest.dev/).
+
+## Reporting issues
+
+Please [open an issue](https://github.com/SimplySF/simply-node/issues) rather than sending a pull request for anything non-trivial without prior discussion.
diff --git a/packages/simply-flow/README.md b/packages/simply-flow/README.md
new file mode 100644
index 00000000..092b0822
--- /dev/null
+++ b/packages/simply-flow/README.md
@@ -0,0 +1,110 @@
+# @simplysf/simply-flow
+
+[](https://npmjs.com/@simplysf/simply-flow) [](https://npmjs.com/@simplysf/simply-flow) [](https://raw.githubusercontent.com/SimplySF/simply-node/main/LICENSE.txt)
+
+## Install
+
+```bash
+sf plugins install @simplysf/simply-flow
+```
+
+## Issues
+
+Please report any issues at https://github.com/SimplySF/simply-node/issues
+
+## Contributing
+
+This package is part of the [`@simplysf/simply`](https://github.com/SimplySF/simply-node) monorepo. See the repo's [CONTRIBUTING.md](https://github.com/SimplySF/simply-node/blob/main/CONTRIBUTING.md) for the repo structure, how to set up and build the project, our commit conventions, and how to submit a pull request. Please also read our [Code of Conduct](https://github.com/SimplySF/simply-node/blob/main/CODE_OF_CONDUCT.md).
+
+## Commands
+
+
+
+- [`sf simply flow delete`](#sf-simply-flow-delete)
+- [`sf simply flow version prune`](#sf-simply-flow-version-prune)
+
+## `sf simply flow delete`
+
+Deactivate and delete every version of one or more Flows.
+
+```
+USAGE
+ $ sf simply flow delete -o [--json] [--flags-dir ] [--api-version ] [-f ] [-n ...]
+
+FLAGS
+ -f, --file= Path to a destructiveChanges.xml/package.xml-shaped file
+ -n, --flow-name=... Flow DeveloperName(s) to delete
+ -o, --target-org= (required) Username or alias of the target org. Not required if the `target-org`
+ configuration variable is already set.
+ --api-version= Override the api version used for api requests made by this command
+
+GLOBAL FLAGS
+ --flags-dir= Import flag values from a directory.
+ --json Format output as json.
+
+DESCRIPTION
+ Deactivate and delete every version of one or more Flows.
+
+ Deactivates every active version of each named Flow (so it no longer counts as "active" against Salesforce's
+ restriction on deleting a Flow that still has one), then hard-deletes every version of it via the Tooling API. This is
+ the pre-step a destructive metadata deploy needs before it can remove a Flow.
+
+ Flows can be named either via `--file`, pointing at a `destructiveChanges.xml`/`package.xml`-shaped file whose `Flow`
+ type members are the flows to delete, or via one or more `--flow-name` flags for scripted or one-off use. Exactly one
+ of the two must be given.
+
+ A failure deactivating or deleting one flow doesn't stop the others from being attempted — every failure is collected
+ and reported, and the command exits non-zero if any occurred.
+
+EXAMPLES
+ $ sf simply flow delete --file destructive/pre/destructiveChanges.xml --target-org myOrg
+
+ $ sf simply flow delete --flow-name My_Flow --flow-name Another_Flow --target-org myOrg
+
+ $ sf simply flow delete --file destructive/pre/destructiveChanges.xml --target-org myOrg --json
+```
+
+_See code: [lib/commands/simply/flow/delete.js](https://github.com/SimplySF/simply-node/blob/@simplysf/simply-flow@0.1.0/packages/simply-flow/lib/commands/simply/flow/delete.js)_
+
+## `sf simply flow version prune`
+
+Delete obsolete versions of Flows found in local source.
+
+```
+USAGE
+ $ sf simply flow version prune -o -d ... [--json] [--flags-dir ] [--api-version ]
+ [--dry-run]
+
+FLAGS
+ -d, --source-dir=... (required) Directories to scan for *.flow-meta.xml files
+ -o, --target-org= (required) Username or alias of the target org. Not required if the `target-org`
+ configuration variable is already set.
+ --api-version= Override the api version used for api requests made by this command
+ --dry-run List obsolete versions without deleting them
+
+GLOBAL FLAGS
+ --flags-dir= Import flag values from a directory.
+ --json Format output as json.
+
+DESCRIPTION
+ Delete obsolete versions of Flows found in local source.
+
+ Scans one or more source directories for `*.flow-meta.xml` files, then deletes any Tooling API Flow version already
+ `Status = 'Obsolete'` for those flows — keeping an org's Flow version history from accumulating indefinitely. Unlike
+ `simply flow delete`, this never touches an active Flow; it only removes versions the org itself already marked
+ obsolete.
+
+ Use `--dry-run` to see what would be deleted without deleting anything.
+
+EXAMPLES
+ $ sf simply flow version prune --target-org myOrg --source-dir sfdx-source/core
+
+ $ sf simply flow version prune --target-org myOrg --source-dir sfdx-source/core --dry-run
+```
+
+_See code: [lib/commands/simply/flow/version/prune.js](https://github.com/SimplySF/simply-node/blob/@simplysf/simply-flow@0.1.0/packages/simply-flow/lib/commands/simply/flow/version/prune.js)_
+
+
+## License
+
+Licensed under the [Apache-2.0](https://raw.githubusercontent.com/SimplySF/simply-node/main/LICENSE.txt) license.
diff --git a/packages/simply-flow/bin/dev.cmd b/packages/simply-flow/bin/dev.cmd
new file mode 100644
index 00000000..aec22ec3
--- /dev/null
+++ b/packages/simply-flow/bin/dev.cmd
@@ -0,0 +1,3 @@
+@echo off
+
+node --loader ts-node/esm --no-warnings=ExperimentalWarning "%~dp0\dev" %*
\ No newline at end of file
diff --git a/packages/simply-flow/bin/dev.js b/packages/simply-flow/bin/dev.js
new file mode 100644
index 00000000..89a549a7
--- /dev/null
+++ b/packages/simply-flow/bin/dev.js
@@ -0,0 +1,8 @@
+#!/usr/bin/env -S node --loader ts-node/esm --no-warnings=ExperimentalWarning
+// eslint-disable-next-line node/shebang
+async function main() {
+ const { execute } = await import('@oclif/core');
+ await execute({ development: true, dir: import.meta.url });
+}
+
+await main();
diff --git a/packages/simply-flow/bin/run.cmd b/packages/simply-flow/bin/run.cmd
new file mode 100644
index 00000000..968fc307
--- /dev/null
+++ b/packages/simply-flow/bin/run.cmd
@@ -0,0 +1,3 @@
+@echo off
+
+node "%~dp0\run" %*
diff --git a/packages/simply-flow/bin/run.js b/packages/simply-flow/bin/run.js
new file mode 100644
index 00000000..cf13fb93
--- /dev/null
+++ b/packages/simply-flow/bin/run.js
@@ -0,0 +1,9 @@
+#!/usr/bin/env node
+
+// eslint-disable-next-line node/shebang
+async function main() {
+ const { execute } = await import('@oclif/core');
+ await execute({ dir: import.meta.url });
+}
+
+await main();
diff --git a/packages/simply-flow/command-snapshot.json b/packages/simply-flow/command-snapshot.json
new file mode 100644
index 00000000..8bf42452
--- /dev/null
+++ b/packages/simply-flow/command-snapshot.json
@@ -0,0 +1,18 @@
+[
+ {
+ "alias": [],
+ "command": "simply:flow:delete",
+ "flagAliases": [],
+ "flagChars": ["f", "n", "o"],
+ "flags": ["api-version", "file", "flags-dir", "flow-name", "json", "target-org"],
+ "plugin": "@simplysf/simply-flow"
+ },
+ {
+ "alias": [],
+ "command": "simply:flow:version:prune",
+ "flagAliases": [],
+ "flagChars": ["d", "o"],
+ "flags": ["api-version", "dry-run", "flags-dir", "json", "source-dir", "target-org"],
+ "plugin": "@simplysf/simply-flow"
+ }
+]
diff --git a/packages/simply-flow/messages/simply.flow.delete.md b/packages/simply-flow/messages/simply.flow.delete.md
new file mode 100644
index 00000000..8b286fb3
--- /dev/null
+++ b/packages/simply-flow/messages/simply.flow.delete.md
@@ -0,0 +1,47 @@
+# summary
+
+Deactivate and delete every version of one or more Flows.
+
+# description
+
+Deactivates every active version of each named Flow (so it no longer counts as "active" against Salesforce's restriction on deleting a Flow that still has one), then hard-deletes every version of it via the Tooling API. This is the pre-step a destructive metadata deploy needs before it can remove a Flow.
+
+Flows can be named either via `--file`, pointing at a `destructiveChanges.xml`/`package.xml`-shaped file whose `Flow` type members are the flows to delete, or via one or more `--flow-name` flags for scripted or one-off use. Exactly one of the two must be given.
+
+A failure deactivating or deleting one flow doesn't stop the others from being attempted — every failure is collected and reported, and the command exits non-zero if any occurred.
+
+# flags.file.summary
+
+Path to a destructiveChanges.xml/package.xml-shaped file
+
+# flags.flow-name.summary
+
+Flow DeveloperName(s) to delete
+
+# examples
+
+- <%= config.bin %> <%= command.id %> --file destructive/pre/destructiveChanges.xml --target-org myOrg
+
+- <%= config.bin %> <%= command.id %> --flow-name My_Flow --flow-name Another_Flow --target-org myOrg
+
+- <%= config.bin %> <%= command.id %> --file destructive/pre/destructiveChanges.xml --target-org myOrg --json
+
+# error.fileOrFlowNameRequired
+
+You must specify either --file or --flow-name, but not both.
+
+# info.nothingToDelete
+
+No Flow members found; nothing to delete.
+
+# info.deactivating
+
+Deactivating active Flow versions...
+
+# info.deleting
+
+Deleting Flow versions...
+
+# info.summary
+
+Deactivated %s flow(s), deleted %s version(s), %s failure(s).
diff --git a/packages/simply-flow/messages/simply.flow.version.prune.md b/packages/simply-flow/messages/simply.flow.version.prune.md
new file mode 100644
index 00000000..3fdfd860
--- /dev/null
+++ b/packages/simply-flow/messages/simply.flow.version.prune.md
@@ -0,0 +1,43 @@
+# summary
+
+Delete obsolete versions of Flows found in local source.
+
+# description
+
+Scans one or more source directories for `*.flow-meta.xml` files, then deletes any Tooling API Flow version already `Status = 'Obsolete'` for those flows — keeping an org's Flow version history from accumulating indefinitely. Unlike `simply flow delete`, this never touches an active Flow; it only removes versions the org itself already marked obsolete.
+
+Use `--dry-run` to see what would be deleted without deleting anything.
+
+# flags.source-dir.summary
+
+Directories to scan for *.flow-meta.xml files
+
+# flags.dry-run.summary
+
+List obsolete versions without deleting them
+
+# examples
+
+- <%= config.bin %> <%= command.id %> --target-org myOrg --source-dir sfdx-source/core
+
+- <%= config.bin %> <%= command.id %> --target-org myOrg --source-dir sfdx-source/core --dry-run
+
+# info.scanningLocalSource
+
+Scanning local source for Flows...
+
+# info.queryingObsoleteVersions
+
+Querying obsolete Flow versions...
+
+# info.deleting
+
+Deleting obsolete Flow versions...
+
+# info.dryRunSummary
+
+Found %s obsolete version(s). Nothing was deleted (--dry-run).
+
+# info.summary
+
+Deleted %s obsolete version(s), %s failure(s).
diff --git a/packages/simply-flow/package.json b/packages/simply-flow/package.json
new file mode 100644
index 00000000..8f57c49c
--- /dev/null
+++ b/packages/simply-flow/package.json
@@ -0,0 +1,200 @@
+{
+ "name": "@simplysf/simply-flow",
+ "description": "Utilities for working with Flows",
+ "version": "0.1.0",
+ "author": "@ClayChipps",
+ "homepage": "https://github.com/SimplySF/simply-node",
+ "bugs": "https://github.com/SimplySF/simply-node/issues",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/SimplySF/simply-node",
+ "directory": "packages/simply-flow"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "files": [
+ "/lib",
+ "/messages",
+ "/npm-shrinkwrap.json",
+ "/oclif.lock",
+ "/oclif.manifest.json"
+ ],
+ "keywords": [
+ "force",
+ "salesforce",
+ "sfdx",
+ "salesforcedx",
+ "sfdx-plugin",
+ "sf-plugin",
+ "sf"
+ ],
+ "license": "Apache-2.0",
+ "exports": "./lib/index.js",
+ "type": "module",
+ "oclif": {
+ "bin": "sf",
+ "commands": "./lib/commands",
+ "devPlugins": [
+ "@oclif/plugin-command-snapshot",
+ "@oclif/plugin-help",
+ "@salesforce/plugin-command-reference"
+ ],
+ "flexibleTaxonomy": true,
+ "plugins": [],
+ "repositoryPrefix": "<%- repo %>/blob/@simplysf/simply-flow@<%- version %>/packages/simply-flow/<%- commandPath %>",
+ "topics": {
+ "simply": {
+ "description": "Commands for simplifying Salesforce",
+ "subtopics": {
+ "flow": {
+ "description": "Commands for working with Flows"
+ }
+ }
+ }
+ },
+ "topicSeparator": " "
+ },
+ "dependencies": {
+ "@oclif/core": "^4.11.2",
+ "@salesforce/core": "^8.30.0",
+ "@salesforce/sf-plugins-core": "^12.2.16",
+ "@simplysf/simply-core": "workspace:^1.4.0",
+ "@simplysf/simply-plugin-kit": "workspace:^1.0.4",
+ "glob": "^13.0.6"
+ },
+ "devDependencies": {
+ "@oclif/plugin-command-snapshot": "^5.3.19",
+ "@salesforce/plugin-command-reference": "^3.1.99",
+ "@vitest/coverage-v8": "^4.1.10",
+ "vitest": "^4.1.10"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "scripts": {
+ "build": "wireit",
+ "compile": "wireit",
+ "fix-license": "eslint src test --fix --rule \"header/header: [2]\"",
+ "format": "wireit",
+ "link-check": "wireit",
+ "lint": "wireit",
+ "readme": "oclif readme --no-aliases",
+ "test": "wireit",
+ "test:coverage": "vitest run --coverage --project simply-flow",
+ "test:nuts": "vitest run --config ../../vitest.nuts.config.ts --project simply-flow",
+ "test:only": "wireit",
+ "test:watch": "vitest watch --project simply-flow"
+ },
+ "wireit": {
+ "build": {
+ "dependencies": [
+ "command-snapshot",
+ "compile",
+ "lint"
+ ]
+ },
+ "compile": {
+ "command": "tsc -p . --pretty --incremental",
+ "files": [
+ "src/**/*.ts",
+ "**/tsconfig.json",
+ "../../tsconfig.json",
+ "messages/**"
+ ],
+ "output": [
+ "lib/**",
+ "*.tsbuildinfo"
+ ],
+ "clean": "if-file-deleted"
+ },
+ "format": {
+ "command": "prettier --write \"+(src|test)/**/*.+(ts|js|json)|command-snapshot.json\"",
+ "files": [
+ "src/**/*.ts",
+ "test/**/*.ts",
+ "command-snapshot.json",
+ ".prettier*"
+ ],
+ "output": []
+ },
+ "lint": {
+ "command": "eslint src test --color --cache --cache-location .eslintcache",
+ "files": [
+ "src/**/*.ts",
+ "test/**/*.ts",
+ "messages/**",
+ "**/.eslint*",
+ "**/tsconfig.json",
+ "../../eslint.config.mjs",
+ "../../tsconfig.json"
+ ],
+ "output": []
+ },
+ "test": {
+ "dependencies": [
+ "test:compile",
+ "test:only",
+ "test:command-reference",
+ "lint",
+ "link-check"
+ ]
+ },
+ "test:command-reference": {
+ "command": "node --loader ts-node/esm --no-warnings=ExperimentalWarning \"./bin/dev.js\" commandreference:generate --erroronwarnings",
+ "files": [
+ "src/**/*.ts",
+ "messages/**",
+ "package.json"
+ ],
+ "output": [
+ "tmp/root"
+ ]
+ },
+ "command-snapshot": {
+ "command": "node --loader ts-node/esm --no-warnings=ExperimentalWarning \"./bin/dev.js\" snapshot:generate && prettier --write command-snapshot.json",
+ "files": [
+ "src/**/*.ts"
+ ],
+ "output": [
+ "command-snapshot.json"
+ ],
+ "dependencies": [
+ "compile"
+ ]
+ },
+ "test:compile": {
+ "command": "tsc -p \"./test\" --pretty",
+ "files": [
+ "test/**/*.ts",
+ "**/tsconfig.json",
+ "../../tsconfig.json"
+ ],
+ "output": []
+ },
+ "test:only": {
+ "command": "vitest run --project simply-flow",
+ "env": {
+ "FORCE_COLOR": "2"
+ },
+ "files": [
+ "test/**/*.ts",
+ "src/**/*.ts",
+ "**/tsconfig.json",
+ "../../tsconfig.json",
+ "vitest.config.ts",
+ "!*.nut.ts"
+ ],
+ "output": []
+ },
+ "link-check": {
+ "command": "node -e \"process.exit(process.env.CI ? 0 : 1)\" || linkinator \"**/*.md\" --skip \"CHANGELOG.md|node_modules|test/|confluence.internal.salesforce.com|my.salesforce.com|localhost|example\\.(com|org|net)|github\\.com/SimplySF/simply-node|raw\\.githubusercontent\\.com/SimplySF/simply-node|blob/.*/lib/|%s\" --markdown --retry --directory-listing --verbosity error",
+ "files": [
+ "./*.md",
+ "./!(CHANGELOG).md",
+ "messages/**/*.md"
+ ],
+ "output": []
+ }
+ }
+}
diff --git a/packages/simply-flow/src/commands/simply/flow/delete.ts b/packages/simply-flow/src/commands/simply/flow/delete.ts
new file mode 100644
index 00000000..710dcde9
--- /dev/null
+++ b/packages/simply-flow/src/commands/simply/flow/delete.ts
@@ -0,0 +1,161 @@
+/*
+ * Copyright (c) 2026, Clay Chipps.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import fs from 'node:fs/promises';
+import { Messages } from '@salesforce/core';
+import { SfCommand, Flags } from '@salesforce/sf-plugins-core';
+import { chunkedInQuery, readPackageManifestMembers } from '@simplysf/simply-core';
+import { requireConnection, targetOrgFlags } from '@simplysf/simply-plugin-kit';
+
+Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
+const messages = Messages.loadMessages('@simplysf/simply-flow', 'simply.flow.delete');
+
+/** How many `Definition.DeveloperName` values to put in each Tooling API `IN (...)` clause. */
+const QUERY_CHUNK_SIZE = 200;
+
+type FlowDefinitionRecord = { Definition: { Id: string; DeveloperName: string } };
+type FlowVersionRecord = { Id: string; Definition: { DeveloperName: string } };
+
+export type FlowDeleteFailure = {
+ developerName: string;
+ stage: 'deactivate' | 'delete';
+ message: string;
+};
+
+export type FlowDeleteResult = {
+ deactivated: string[];
+ deleted: string[];
+ failures: FlowDeleteFailure[];
+};
+
+const FAILURE_TABLE_COLUMNS = [
+ { key: 'developerName' as const, name: 'FLOW' },
+ { key: 'stage' as const, name: 'STAGE' },
+ { key: 'message' as const, name: 'MESSAGE' },
+];
+
+/**
+ * Deactivates and hard-deletes every version of the named Flows — the pre-step a destructive
+ * metadata deploy needs, since Salesforce won't remove a Flow that still has an active version.
+ */
+export default class FlowDelete extends SfCommand {
+ public static readonly summary = messages.getMessage('summary');
+ public static readonly description = messages.getMessage('description');
+ public static readonly examples = messages.getMessages('examples');
+
+ public static readonly flags = {
+ ...SfCommand.baseFlags,
+ ...targetOrgFlags,
+ file: Flags.string({ summary: messages.getMessage('flags.file.summary'), char: 'f' }),
+ 'flow-name': Flags.string({
+ summary: messages.getMessage('flags.flow-name.summary'),
+ char: 'n',
+ multiple: true,
+ }),
+ };
+
+ /** @returns Which flows were deactivated/deleted, and any per-flow failures. Sets `process.exitCode = 1` if any failure occurred. */
+ public async run(): Promise {
+ const { flags } = await this.parse(FlowDelete);
+
+ const hasFlowNames = Boolean(flags['flow-name']?.length);
+ if ((flags.file && hasFlowNames) || (!flags.file && !hasFlowNames)) {
+ throw messages.createError('error.fileOrFlowNameRequired');
+ }
+
+ const flowNames = flags.file
+ ? readPackageManifestMembers(await fs.readFile(flags.file, 'utf-8'), 'Flow')
+ : (flags['flow-name'] as string[]);
+
+ if (flowNames.length === 0) {
+ this.info(messages.getMessage('info.nothingToDelete'));
+ return { deactivated: [], deleted: [], failures: [] };
+ }
+
+ const connection = requireConnection(flags);
+ const failures: FlowDeleteFailure[] = [];
+
+ this.spinner.start(messages.getMessage('info.deactivating'));
+ const definitionRecords = await chunkedInQuery(
+ connection,
+ flowNames,
+ (inClause) =>
+ `SELECT Definition.Id, Definition.DeveloperName FROM Flow WHERE Definition.DeveloperName IN (${inClause})`,
+ { chunkSize: QUERY_CHUNK_SIZE, tooling: true },
+ );
+
+ const distinctDefinitions = new Map();
+ for (const record of definitionRecords) {
+ distinctDefinitions.set(record.Definition.Id, record.Definition.DeveloperName);
+ }
+
+ const deactivated: string[] = [];
+ for (const [definitionId, developerName] of distinctDefinitions) {
+ try {
+ // eslint-disable-next-line no-await-in-loop -- deactivating one FlowDefinition per iteration; failures must attribute to the right flow
+ const result = await connection.tooling.sobject('FlowDefinition').update({
+ Id: definitionId,
+ Metadata: { activeVersionNumber: 0 },
+ });
+ if (result.success) {
+ deactivated.push(developerName);
+ } else {
+ failures.push({
+ developerName,
+ stage: 'deactivate',
+ message: result.errors.map((e) => e.message).join(', '),
+ });
+ }
+ } catch (error) {
+ failures.push({ developerName, stage: 'deactivate', message: (error as Error).message });
+ }
+ }
+ this.spinner.stop();
+
+ this.spinner.start(messages.getMessage('info.deleting'));
+ const versionRecords = await chunkedInQuery(
+ connection,
+ flowNames,
+ (inClause) => `SELECT Id, Definition.DeveloperName FROM Flow WHERE Definition.DeveloperName IN (${inClause})`,
+ { chunkSize: QUERY_CHUNK_SIZE, tooling: true },
+ );
+
+ const deleted: string[] = [];
+ for (const version of versionRecords) {
+ const developerName = version.Definition.DeveloperName;
+ try {
+ // eslint-disable-next-line no-await-in-loop -- deleting one Flow version per iteration; the Tooling API has no bulk destroy for this object
+ const result = await connection.tooling.sobject('Flow').destroy(version.Id);
+ if (result.success) {
+ deleted.push(developerName);
+ } else {
+ failures.push({ developerName, stage: 'delete', message: result.errors.map((e) => e.message).join(', ') });
+ }
+ } catch (error) {
+ failures.push({ developerName, stage: 'delete', message: (error as Error).message });
+ }
+ }
+ this.spinner.stop();
+
+ if (failures.length > 0) {
+ this.table({ data: failures, columns: FAILURE_TABLE_COLUMNS });
+ process.exitCode = 1;
+ }
+ this.info(messages.getMessage('info.summary', [deactivated.length, deleted.length, failures.length]));
+
+ return { deactivated, deleted, failures };
+ }
+}
diff --git a/packages/simply-flow/src/commands/simply/flow/version/prune.ts b/packages/simply-flow/src/commands/simply/flow/version/prune.ts
new file mode 100644
index 00000000..8fa3fba1
--- /dev/null
+++ b/packages/simply-flow/src/commands/simply/flow/version/prune.ts
@@ -0,0 +1,148 @@
+/*
+ * Copyright (c) 2026, Clay Chipps.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import path from 'node:path';
+import { Messages } from '@salesforce/core';
+import { SfCommand, Flags } from '@salesforce/sf-plugins-core';
+import { chunkedInQuery } from '@simplysf/simply-core';
+import { requireConnection, targetOrgFlags } from '@simplysf/simply-plugin-kit';
+import { glob } from 'glob';
+
+Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
+const messages = Messages.loadMessages('@simplysf/simply-flow', 'simply.flow.version.prune');
+
+/** How many `Definition.DeveloperName` values to put in each Tooling API `IN (...)` clause. */
+const QUERY_CHUNK_SIZE = 200;
+
+type ObsoleteFlowVersionRecord = { Id: string; Definition: { DeveloperName: string } };
+
+export type FlowVersionPruneCandidate = { id: string; developerName: string };
+export type FlowVersionPruneFailure = { developerName: string; message: string };
+
+export type FlowVersionPruneResult = {
+ dryRun: boolean;
+ candidates: FlowVersionPruneCandidate[];
+ deleted: string[];
+ failures: FlowVersionPruneFailure[];
+};
+
+const CANDIDATE_TABLE_COLUMNS = [
+ { key: 'developerName' as const, name: 'FLOW' },
+ { key: 'id' as const, name: 'VERSION ID' },
+];
+
+const FAILURE_TABLE_COLUMNS = [
+ { key: 'developerName' as const, name: 'FLOW' },
+ { key: 'message' as const, name: 'MESSAGE' },
+];
+
+/** @returns The Flow DeveloperName encoded in a `.flow-meta.xml` file's basename. */
+function developerNameFromFlowFile(flowFile: string): string {
+ return path.parse(flowFile).name.replace('.flow-meta', '');
+}
+
+/**
+ * Deletes obsolete Flow versions (`Status = 'Obsolete'`) for the Flows found under `--source-dir`,
+ * to keep an org's Flow version history from accumulating indefinitely. Unlike `flow delete`, this
+ * never removes an active Flow — only versions the org itself already marked obsolete.
+ */
+export default class FlowVersionPrune extends SfCommand {
+ public static readonly summary = messages.getMessage('summary');
+ public static readonly description = messages.getMessage('description');
+ public static readonly examples = messages.getMessages('examples');
+
+ public static readonly flags = {
+ ...SfCommand.baseFlags,
+ ...targetOrgFlags,
+ 'source-dir': Flags.directory({
+ summary: messages.getMessage('flags.source-dir.summary'),
+ char: 'd',
+ exists: true,
+ multiple: true,
+ required: true,
+ }),
+ 'dry-run': Flags.boolean({ summary: messages.getMessage('flags.dry-run.summary'), default: false }),
+ };
+
+ /** @returns The obsolete versions found (and deleted, unless `--dry-run`), plus any per-version failures. Sets `process.exitCode = 1` if any failure occurred. */
+ public async run(): Promise {
+ const { flags } = await this.parse(FlowVersionPrune);
+
+ this.spinner.start(messages.getMessage('info.scanningLocalSource'));
+ const flowFiles = (
+ await Promise.all(
+ flags['source-dir'].map((sourceDir) => glob(`${sourceDir.replaceAll('\\', '/')}/**/*.flow-meta.xml`)),
+ )
+ ).flat();
+ const flowNames = [...new Set(flowFiles.map(developerNameFromFlowFile))];
+ this.spinner.stop();
+
+ const connection = requireConnection(flags);
+
+ this.spinner.start(messages.getMessage('info.queryingObsoleteVersions'));
+ const obsoleteRecords = await chunkedInQuery(
+ connection,
+ flowNames,
+ (inClause) =>
+ `SELECT Id, Definition.DeveloperName FROM Flow WHERE Status = 'Obsolete' AND Definition.DeveloperName IN (${inClause})`,
+ { chunkSize: QUERY_CHUNK_SIZE, tooling: true },
+ );
+ this.spinner.stop();
+
+ const candidates: FlowVersionPruneCandidate[] = obsoleteRecords.map((record) => ({
+ id: record.Id,
+ developerName: record.Definition.DeveloperName,
+ }));
+
+ if (flags['dry-run']) {
+ if (candidates.length > 0) {
+ this.table({ data: candidates, columns: CANDIDATE_TABLE_COLUMNS });
+ }
+ this.info(messages.getMessage('info.dryRunSummary', [candidates.length]));
+ return { dryRun: true, candidates, deleted: [], failures: [] };
+ }
+
+ const failures: FlowVersionPruneFailure[] = [];
+ const deleted: string[] = [];
+
+ this.spinner.start(messages.getMessage('info.deleting'));
+ for (const candidate of candidates) {
+ try {
+ // eslint-disable-next-line no-await-in-loop -- deleting one Flow version per iteration; the Tooling API has no bulk destroy for this object
+ const result = await connection.tooling.sobject('Flow').destroy(candidate.id);
+ if (result.success) {
+ deleted.push(candidate.developerName);
+ } else {
+ failures.push({
+ developerName: candidate.developerName,
+ message: result.errors.map((e) => e.message).join(', '),
+ });
+ }
+ } catch (error) {
+ failures.push({ developerName: candidate.developerName, message: (error as Error).message });
+ }
+ }
+ this.spinner.stop();
+
+ if (failures.length > 0) {
+ this.table({ data: failures, columns: FAILURE_TABLE_COLUMNS });
+ process.exitCode = 1;
+ }
+ this.info(messages.getMessage('info.summary', [deleted.length, failures.length]));
+
+ return { dryRun: false, candidates, deleted, failures };
+ }
+}
diff --git a/packages/simply-flow/src/index.ts b/packages/simply-flow/src/index.ts
new file mode 100644
index 00000000..caf745ca
--- /dev/null
+++ b/packages/simply-flow/src/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright (c) 2026, Clay Chipps.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export default {};
diff --git a/packages/simply-flow/test/commands/simply/flow/delete.test.ts b/packages/simply-flow/test/commands/simply/flow/delete.test.ts
new file mode 100644
index 00000000..365611ca
--- /dev/null
+++ b/packages/simply-flow/test/commands/simply/flow/delete.test.ts
@@ -0,0 +1,146 @@
+/*
+ * Copyright (c) 2026, Clay Chipps.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import fs from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import { Connection } from '@salesforce/core';
+import { MockTestOrgData, TestContext } from '@salesforce/core/testSetup';
+import sinon, { type SinonStub } from 'sinon';
+import { afterEach, beforeAll, describe, expect, it } from 'vitest';
+import FlowDelete from '../../../../src/commands/simply/flow/delete.js';
+
+const NO_FLOW_MANIFEST =
+ '\n' +
+ '\n' +
+ ' \n My_Permission_Set\n PermissionSet\n \n' +
+ ' 62.0\n\n';
+
+async function writeManifest(xml: string): Promise {
+ const filePath = path.join(
+ os.tmpdir(),
+ `simply-flow-delete-${Date.now()}-${Math.random().toString(36).slice(2)}.xml`,
+ );
+ await fs.writeFile(filePath, xml);
+ return filePath;
+}
+
+describe('simply flow delete', () => {
+ const $$ = new TestContext({ sinon });
+ const testOrg = new MockTestOrgData();
+
+ beforeAll(async () => {
+ await $$.stubAuths(testOrg);
+ });
+
+ afterEach(() => {
+ $$.restore();
+ process.exitCode = undefined;
+ });
+
+ it('is a no-op when the file has no Flow members', async () => {
+ const filePath = await writeManifest(NO_FLOW_MANIFEST);
+ const autoFetchQuery = $$.SANDBOX.stub(Connection.prototype, 'autoFetchQuery');
+
+ try {
+ const result = await FlowDelete.run(['--file', filePath, '--target-org', testOrg.username]);
+
+ expect(result).to.deep.equal({ deactivated: [], deleted: [], failures: [] });
+ expect(autoFetchQuery.called).to.be.false;
+ } finally {
+ await fs.rm(filePath, { force: true });
+ }
+ });
+
+ it('rejects --file combined with --flow-name', async () => {
+ const filePath = await writeManifest(NO_FLOW_MANIFEST);
+
+ try {
+ await expect(
+ FlowDelete.run(['--file', filePath, '--flow-name', 'My_Flow', '--target-org', testOrg.username]),
+ ).rejects.toThrow();
+ } finally {
+ await fs.rm(filePath, { force: true });
+ }
+ });
+
+ it('rejects when neither --file nor --flow-name is given', async () => {
+ await expect(FlowDelete.run(['--target-org', testOrg.username])).rejects.toThrow();
+ });
+
+ it('records a deactivation failure but still deletes every version, for both flows', async () => {
+ $$.SANDBOX.stub(Connection.prototype, 'autoFetchQuery').callsFake(async (soql: string) => {
+ if (soql.startsWith('SELECT Definition.Id')) {
+ return {
+ records: [
+ { Definition: { Id: '300000000000001AAA', DeveloperName: 'Flow_A' } },
+ { Definition: { Id: '300000000000002AAA', DeveloperName: 'Flow_B' } },
+ ],
+ done: true,
+ totalSize: 2,
+ } as never;
+ }
+ if (soql.startsWith('SELECT Id, Definition.DeveloperName')) {
+ return {
+ records: [
+ { Id: '301000000000001AAA', Definition: { DeveloperName: 'Flow_A' } },
+ { Id: '301000000000002AAA', Definition: { DeveloperName: 'Flow_B' } },
+ ],
+ done: true,
+ totalSize: 2,
+ } as never;
+ }
+ throw new Error(`Unexpected query: ${soql}`);
+ });
+
+ (Connection.prototype.request as unknown as SinonStub).callsFake(
+ async (request: { method: string; url: string }) => {
+ const { method, url } = request;
+
+ if (method === 'PATCH' && url.endsWith('/tooling/sobjects/FlowDefinition/300000000000001AAA')) {
+ return { success: false, errors: [{ message: 'Cannot deactivate' }] };
+ }
+ if (method === 'PATCH' && url.endsWith('/tooling/sobjects/FlowDefinition/300000000000002AAA')) {
+ return { id: '300000000000002AAA', success: true, errors: [] };
+ }
+ if (method === 'DELETE' && url.endsWith('/tooling/sobjects/Flow/301000000000001AAA')) {
+ return { id: '301000000000001AAA', success: true, errors: [] };
+ }
+ if (method === 'DELETE' && url.endsWith('/tooling/sobjects/Flow/301000000000002AAA')) {
+ return { id: '301000000000002AAA', success: true, errors: [] };
+ }
+
+ throw new Error(`Unexpected request: ${method} ${url}`);
+ },
+ );
+
+ const result = await FlowDelete.run([
+ '--flow-name',
+ 'Flow_A',
+ '--flow-name',
+ 'Flow_B',
+ '--target-org',
+ testOrg.username,
+ ]);
+
+ expect(result.deactivated).to.deep.equal(['Flow_B']);
+ expect(result.deleted).to.deep.equal(['Flow_A', 'Flow_B']);
+ expect(result.failures).to.deep.equal([
+ { developerName: 'Flow_A', stage: 'deactivate', message: 'Cannot deactivate' },
+ ]);
+ expect(process.exitCode).to.equal(1);
+ });
+});
diff --git a/packages/simply-flow/test/commands/simply/flow/version/prune.test.ts b/packages/simply-flow/test/commands/simply/flow/version/prune.test.ts
new file mode 100644
index 00000000..31e72b33
--- /dev/null
+++ b/packages/simply-flow/test/commands/simply/flow/version/prune.test.ts
@@ -0,0 +1,82 @@
+/*
+ * Copyright (c) 2026, Clay Chipps.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { Connection } from '@salesforce/core';
+import { MockTestOrgData, TestContext } from '@salesforce/core/testSetup';
+import sinon, { type SinonStub } from 'sinon';
+import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
+import FlowVersionPrune from '../../../../../src/commands/simply/flow/version/prune.js';
+
+describe('simply flow version prune', () => {
+ const $$ = new TestContext({ sinon });
+ const testOrg = new MockTestOrgData();
+ let tmpDir: string;
+
+ beforeAll(async () => {
+ await $$.stubAuths(testOrg);
+ });
+
+ beforeEach(() => {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'simply-flow-version-prune-'));
+ fs.writeFileSync(path.join(tmpDir, 'My_Flow.flow-meta.xml'), '');
+ });
+
+ afterEach(() => {
+ $$.restore();
+ fs.rmSync(tmpDir, { force: true, recursive: true });
+ process.exitCode = undefined;
+ });
+
+ it('--dry-run lists candidates without deleting anything', async () => {
+ $$.SANDBOX.stub(Connection.prototype, 'autoFetchQuery').resolves({
+ records: [{ Id: '301000000000001AAA', Definition: { DeveloperName: 'My_Flow' } }],
+ done: true,
+ totalSize: 1,
+ } as never);
+
+ const result = await FlowVersionPrune.run(['--target-org', testOrg.username, '--source-dir', tmpDir, '--dry-run']);
+
+ expect(result.dryRun).to.be.true;
+ expect(result.candidates).to.deep.equal([{ id: '301000000000001AAA', developerName: 'My_Flow' }]);
+ expect(result.deleted).to.deep.equal([]);
+ });
+
+ it('deletes every obsolete version found when not a dry run', async () => {
+ $$.SANDBOX.stub(Connection.prototype, 'autoFetchQuery').resolves({
+ records: [{ Id: '301000000000001AAA', Definition: { DeveloperName: 'My_Flow' } }],
+ done: true,
+ totalSize: 1,
+ } as never);
+ (Connection.prototype.request as unknown as SinonStub).callsFake(
+ async (request: { method: string; url: string }) => {
+ if (request.method === 'DELETE' && request.url.endsWith('/tooling/sobjects/Flow/301000000000001AAA')) {
+ return { id: '301000000000001AAA', success: true, errors: [] };
+ }
+ throw new Error(`Unexpected request: ${request.method} ${request.url}`);
+ },
+ );
+
+ const result = await FlowVersionPrune.run(['--target-org', testOrg.username, '--source-dir', tmpDir]);
+
+ expect(result.dryRun).to.be.false;
+ expect(result.deleted).to.deep.equal(['My_Flow']);
+ expect(result.failures).to.deep.equal([]);
+ expect(process.exitCode).to.equal(undefined);
+ });
+});
diff --git a/packages/simply-flow/test/tsconfig.json b/packages/simply-flow/test/tsconfig.json
new file mode 100644
index 00000000..c1591a76
--- /dev/null
+++ b/packages/simply-flow/test/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "../../../tsconfig.json",
+ "include": ["./**/*.ts"]
+}
diff --git a/packages/simply-flow/tsconfig.json b/packages/simply-flow/tsconfig.json
new file mode 100644
index 00000000..cfccc5a8
--- /dev/null
+++ b/packages/simply-flow/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "noEmit": false,
+ "outDir": "lib",
+ "rootDir": "src"
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/packages/simply-permissions/README.md b/packages/simply-permissions/README.md
index 82d1aea1..b5144994 100644
--- a/packages/simply-permissions/README.md
+++ b/packages/simply-permissions/README.md
@@ -65,6 +65,121 @@ FLAG DESCRIPTIONS
The path to write the generated HTML report to.
```
+_See code: [lib/commands/simply/permissions/analyze.js](https://github.com/SimplySF/simply-node/blob/@simplysf/simply-permissions@1.2.31/packages/simply-permissions/lib/commands/simply/permissions/analyze.js)_
+
+## `sf simply permissions build`
+
+Generate a permission set from Salesforce source metadata.
+
+```
+USAGE
+ $ sf simply permissions build --type read-only|view-all|modify-all -n -d --output [--json]
+ [--flags-dir ] [-c ] [--include-record-types] [--label ] [--description ]
+
+FLAGS
+ -c, --config= Path to a permission set configuration file
+ -d, --directory= (required) Path to the Salesforce project directory
+ -n, --name= (required) API name for the permission set
+ --description= Description for the permission set
+ --include-record-types Include record type visibilities
+ --label= Label for the permission set
+ --output= (required) Output directory
+ --type=