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 + +[![NPM](https://img.shields.io/npm/v/@simplysf/simply-flow?label=@simplysf/simply-flow)](https://npmjs.com/@simplysf/simply-flow) [![Downloads/week](https://img.shields.io/npm/dw/@simplysf/simply-flow.svg)](https://npmjs.com/@simplysf/simply-flow) [![License: Apache-2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](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=