sf-plugin-permission-sets 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/LICENSE.md +28 -0
  2. package/README.md +181 -29
  3. package/lib/adapters/connection-org-client.d.ts +11 -5
  4. package/lib/adapters/connection-org-client.d.ts.map +1 -1
  5. package/lib/adapters/connection-org-client.js +219 -106
  6. package/lib/adapters/connection-org-client.js.map +1 -1
  7. package/lib/adapters/index.d.ts +2 -0
  8. package/lib/adapters/index.d.ts.map +1 -0
  9. package/lib/adapters/index.js +2 -0
  10. package/lib/adapters/index.js.map +1 -0
  11. package/lib/commands/ps/apply.d.ts +13 -0
  12. package/lib/commands/ps/apply.d.ts.map +1 -1
  13. package/lib/commands/ps/apply.js +83 -34
  14. package/lib/commands/ps/apply.js.map +1 -1
  15. package/lib/commands/ps/check.d.ts +3 -1
  16. package/lib/commands/ps/check.d.ts.map +1 -1
  17. package/lib/commands/ps/check.js +15 -6
  18. package/lib/commands/ps/check.js.map +1 -1
  19. package/lib/commands/ps/export.d.ts +11 -2
  20. package/lib/commands/ps/export.d.ts.map +1 -1
  21. package/lib/commands/ps/export.js +53 -7
  22. package/lib/commands/ps/export.js.map +1 -1
  23. package/lib/commands/ps/plan.d.ts +13 -1
  24. package/lib/commands/ps/plan.d.ts.map +1 -1
  25. package/lib/commands/ps/plan.js +87 -30
  26. package/lib/commands/ps/plan.js.map +1 -1
  27. package/lib/commands/ps/validate.d.ts +3 -1
  28. package/lib/commands/ps/validate.d.ts.map +1 -1
  29. package/lib/commands/ps/validate.js +16 -7
  30. package/lib/commands/ps/validate.js.map +1 -1
  31. package/lib/core/diff.js +1 -1
  32. package/lib/core/diff.js.map +1 -1
  33. package/lib/core/finding.d.ts +3 -2
  34. package/lib/core/finding.d.ts.map +1 -1
  35. package/lib/core/finding.js +10 -2
  36. package/lib/core/finding.js.map +1 -1
  37. package/lib/core/index.d.ts +10 -0
  38. package/lib/core/index.d.ts.map +1 -0
  39. package/lib/core/index.js +9 -0
  40. package/lib/core/index.js.map +1 -0
  41. package/lib/core/load.d.ts.map +1 -1
  42. package/lib/core/load.js +17 -10
  43. package/lib/core/load.js.map +1 -1
  44. package/lib/core/mode.d.ts +18 -0
  45. package/lib/core/mode.d.ts.map +1 -0
  46. package/lib/core/mode.js +16 -0
  47. package/lib/core/mode.js.map +1 -0
  48. package/lib/core/model.d.ts +13 -8
  49. package/lib/core/model.d.ts.map +1 -1
  50. package/lib/core/normalize.d.ts +2 -0
  51. package/lib/core/normalize.d.ts.map +1 -1
  52. package/lib/core/normalize.js +15 -5
  53. package/lib/core/normalize.js.map +1 -1
  54. package/lib/core/report.d.ts +2 -1
  55. package/lib/core/report.d.ts.map +1 -1
  56. package/lib/core/report.js +43 -24
  57. package/lib/core/report.js.map +1 -1
  58. package/lib/core/schema.d.ts +2 -12
  59. package/lib/core/schema.d.ts.map +1 -1
  60. package/lib/core/schema.js +2 -2
  61. package/lib/core/schema.js.map +1 -1
  62. package/lib/core/serialize.d.ts.map +1 -1
  63. package/lib/core/serialize.js +3 -1
  64. package/lib/core/serialize.js.map +1 -1
  65. package/lib/services/adapters/index.d.ts +2 -0
  66. package/lib/services/adapters/index.d.ts.map +1 -0
  67. package/lib/services/adapters/index.js +2 -0
  68. package/lib/services/adapters/index.js.map +1 -0
  69. package/lib/services/adapters/org-client.d.ts +10 -6
  70. package/lib/services/adapters/org-client.d.ts.map +1 -1
  71. package/lib/services/apply.d.ts +10 -11
  72. package/lib/services/apply.d.ts.map +1 -1
  73. package/lib/services/apply.js +29 -38
  74. package/lib/services/apply.js.map +1 -1
  75. package/lib/services/check.d.ts +3 -7
  76. package/lib/services/check.d.ts.map +1 -1
  77. package/lib/services/check.js +5 -12
  78. package/lib/services/check.js.map +1 -1
  79. package/lib/services/export.d.ts +11 -6
  80. package/lib/services/export.d.ts.map +1 -1
  81. package/lib/services/export.js +14 -10
  82. package/lib/services/export.js.map +1 -1
  83. package/lib/services/index.d.ts +6 -0
  84. package/lib/services/index.d.ts.map +1 -0
  85. package/lib/services/index.js +6 -0
  86. package/lib/services/index.js.map +1 -0
  87. package/lib/services/plan.d.ts +4 -10
  88. package/lib/services/plan.d.ts.map +1 -1
  89. package/lib/services/plan.js +22 -18
  90. package/lib/services/plan.js.map +1 -1
  91. package/lib/services/resolution.d.ts +7 -6
  92. package/lib/services/resolution.d.ts.map +1 -1
  93. package/lib/services/resolution.js +32 -9
  94. package/lib/services/resolution.js.map +1 -1
  95. package/lib/services/validate.d.ts +8 -7
  96. package/lib/services/validate.d.ts.map +1 -1
  97. package/lib/services/validate.js +29 -13
  98. package/lib/services/validate.js.map +1 -1
  99. package/messages/ps.check.md +1 -1
  100. package/messages/ps.export.md +21 -1
  101. package/messages/ps.validate.md +2 -2
  102. package/package.json +25 -17
package/LICENSE.md ADDED
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Isaac Ferreira
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # sf-plugin-permission-sets
2
2
 
3
- [![NPM](https://img.shields.io/npm/v/sf-plugin-permission-sets.svg?label=sf-plugin-permission-sets)](https://www.npmjs.com/package/sf-plugin-permission-sets) [![Downloads/week](https://img.shields.io/npm/dw/sf-plugin-permission-sets.svg)](https://npmjs.org/package/sf-plugin-permission-sets) [![Stability: experimental](https://img.shields.io/badge/stability-experimental-orange.svg)](https://semver.org/#spec-item-4) [![License](https://img.shields.io/badge/License-BSD%203--Clause-brightgreen.svg)](https://raw.githubusercontent.com/zaclummys/sf-plugin-permission-sets/main/LICENSE.txt)
3
+ [![NPM](https://img.shields.io/npm/v/sf-plugin-permission-sets.svg?label=sf-plugin-permission-sets)](https://www.npmjs.com/package/sf-plugin-permission-sets) [![Downloads/week](https://img.shields.io/npm/dw/sf-plugin-permission-sets.svg)](https://npmjs.org/package/sf-plugin-permission-sets) [![Stability: experimental](https://img.shields.io/badge/stability-experimental-orange.svg)](https://semver.org/#spec-item-4) [![License](https://img.shields.io/badge/License-BSD%203--Clause-brightgreen.svg)](https://raw.githubusercontent.com/zaclummys/sf-plugin-permission-sets/main/LICENSE.md)
4
4
 
5
5
  > Declarative, GitOps-style management of **permission set assignments** for Salesforce orgs.
6
6
  > Define who gets what in version-controlled YAML. The plugin reconciles your org to match it: `plan` then `apply`, just like Terraform.
@@ -21,9 +21,11 @@ Stop clicking through Setup to grant access. Commit a YAML file, open a PR, let
21
21
  - [Modes](#modes)
22
22
  - [Validations](#validations)
23
23
  - [Commands](#commands)
24
+ - [GitHub Actions](#github-actions)
24
25
  - [Inspiration & equivalents](#inspiration--equivalents)
25
26
  - [Versioning](#versioning)
26
27
  - [Architecture](#architecture)
28
+ - [Development](#development)
27
29
 
28
30
  ---
29
31
 
@@ -36,7 +38,7 @@ This plugin makes the desired state **declarative and reviewable**:
36
38
  - ✅ **Single source of truth:** the YAML in git is authoritative, and the org is reconciled to it.
37
39
  - ✅ **Plan before apply:** see exactly what will be added/removed before anything changes.
38
40
  - ✅ **Safe by default:** deletions are opt-in and guarded by a delete threshold.
39
- - ✅ **CI-native:** fully offline `check`, exit codes for gating, and `--json` on every command.
41
+ - ✅ **CI-native:** `check` needs no org, exit codes for gating, and `--json` on every command.
40
42
  - ✅ **Flexible at the edges:** pick your file layout (by permission set or by user) and your sync mode.
41
43
  - ✅ **GitOps for access, the SFDX way:** assignments live in source and ship through the same git and CI pipeline as your metadata, instead of being clicked into Setup by hand.
42
44
  - ✅ **Fewer hands in Setup for higher environments:** because access is applied from git through CI, fewer people need direct Setup access in UAT and production, and every change is a reviewed pull request with a git audit trail.
@@ -53,33 +55,33 @@ Or pin a version:
53
55
  sf plugins install sf-plugin-permission-sets@x.y.z
54
56
  ```
55
57
 
56
- Requires Salesforce CLI (`sf`) and Node.js 18+.
58
+ Requires Salesforce CLI (`sf`) and Node.js 20+.
57
59
 
58
60
  ## Quick start
59
61
 
60
62
  ```bash
61
63
  # 1. Bootstrap YAML from an existing org (so you don't start from scratch)
62
- sf ps export --target-org dev --output-file permissions.yml
64
+ sf ps export --target-org dev --output-file permissions/dev.yml
63
65
 
64
- # 2. Edit the files, commit, open a PR. Validate offline, no org needed:
65
- sf ps check --file "./permissions/*.yml"
66
+ # 2. Edit the files, commit, open a PR. Check them, no org needed:
67
+ sf ps check --file "permissions/*.yml"
66
68
 
67
69
  # 3. Validate against a real org (do the users/permission sets exist?)
68
- sf ps validate --file "./permissions/*.yml" --target-org dev
70
+ sf ps validate --file "permissions/*.yml" --target-org dev
69
71
 
70
72
  # 4. See what would change
71
- sf ps plan --file "./permissions/*.yml" --target-org dev
73
+ sf ps plan --file "permissions/*.yml" --target-org dev
72
74
 
73
75
  # 5. Apply it (additive by default, only adds)
74
- sf ps apply --file "./permissions/*.yml" --target-org dev
76
+ sf ps apply --file "permissions/*.yml" --target-org dev
75
77
 
76
78
  # 6. Full reconcile, including removals (opt-in)
77
- sf ps apply --file "./permissions/*.yml" --target-org prod --mode sync
79
+ sf ps apply --file "permissions/*.yml" --target-org prod --mode sync
78
80
  ```
79
81
 
80
82
  ## Permission files
81
83
 
82
- You point every command at one or more YAML files with `--file` (alias `-f`).
84
+ `check`, `validate`, `plan`, and `apply` read one or more YAML files with `--file` (alias `-f`). (`export` writes YAML rather than reading it, so there `-f` is the output file.)
83
85
 
84
86
  Multiple files are merged into one model, so splitting by team is encouraged. The files contain **only declarative data**: knobs like sync mode and exclusions are CLI flags (see [Commands](#commands)), so there's no separate config format to learn yet. Each top-level key is unique within a file, and `check` flags duplicates.
85
87
 
@@ -191,15 +193,15 @@ A run performs three operations: **add** missing assignments, **update** changed
191
193
 
192
194
  ## Validations
193
195
 
194
- Every run checks the files first. `check` runs the offline checks with no org, and `validate` adds the org-side checks. When files merge, most overlaps are unions rather than errors.
196
+ Every run checks the files first. `check` runs the file checks with no org, and `validate` adds the org-side checks. When files merge, most overlaps are unions rather than errors.
195
197
 
196
198
  | Situation | Checked by | Severity | Result |
197
199
  | --- | --- | :---: | --- |
198
- | Same user in two files with different targets | `check` (offline) | ✅ ok | Merged into one model, the point of slicing |
199
- | Same target listed twice for a user | `check` (offline) | ⚠️ warning | Deduped |
200
- | A user with no scopes, or an empty list | `check` (offline) | ⚠️ warning | Ignored as a no-op |
201
- | Same username key appears twice in one file | `check` (offline) | ❌ error | Rejected, the intent is ambiguous |
202
- | Declared user, permission set, group, or license missing or not unique | `validate` (online) | ❌ error | Run fails before any change |
200
+ | Same user in two files with different targets | `check` | ✅ ok | Merged into one model, the point of slicing |
201
+ | Same target listed twice for a user | `check` | ⚠️ warning | Deduped |
202
+ | A user with no scopes, or an empty list | `check` | ⚠️ warning | Ignored as a no-op |
203
+ | Same username key appears twice in one file | `check` | ❌ error | Rejected, the intent is ambiguous |
204
+ | Declared user, permission set, group, or license missing or not unique | `validate` | ❌ error | Run fails before any change |
203
205
 
204
206
  ## Commands
205
207
 
@@ -213,7 +215,7 @@ Every run checks the files first. `check` runs the offline checks with no org, a
213
215
 
214
216
  ### `sf ps check`
215
217
 
216
- Fully offline: runs in any CI job or pre-commit hook without org credentials.
218
+ Needs no org: runs in any CI job or pre-commit hook without org credentials.
217
219
 
218
220
  ```
219
221
  USAGE
@@ -241,7 +243,7 @@ FLAGS
241
243
  -o, --target-org=<org> (required) Org to resolve against.
242
244
  -f, --file=<glob>... (required) YAML file(s) to read. Repeatable, globs expanded by the plugin.
243
245
 
244
- Runs all offline checks, then verifies that every user (active), permission set,
246
+ Runs everything `check` does, then verifies that every user (active), permission set,
245
247
  group, and license referenced actually exists and resolves uniquely.
246
248
  ```
247
249
 
@@ -306,8 +308,8 @@ Next: sf ps apply -o prod -f "permissions/*.yml" --mode sync
306
308
 
307
309
  ```
308
310
  USAGE
309
- $ sf ps apply -o <org> -f <glob>... [--mode <value>] [--max-deletes <n>]
310
- [--dry-run] [--show-unchanged] [--no-prompt] [--json]
311
+ $ sf ps apply -o <org> -f <glob>... [--mode <value>]
312
+ [--max-deletes <n>] [--dry-run] [--show-unchanged] [--no-prompt] [--json]
311
313
 
312
314
  FLAGS
313
315
  -o, --target-org=<org> (required)
@@ -319,23 +321,152 @@ FLAGS
319
321
  --no-prompt Skip the deletion confirmation prompt (for CI).
320
322
  ```
321
323
 
324
+ `apply` recomputes from the files every run: it re-reads the YAML, re-resolves every reference to an org id, and re-diffs against live state, then acts per `--mode`. Run `plan` shortly before `apply` so the preview you review reflects what `apply` will do (an edited file, a renamed permission set, or another admin's change between the two shifts the outcome).
325
+
322
326
  Deletions always prompt for confirmation unless `--no-prompt` is set, and are hard-capped by `--max-deletes` so a bad merge can't unassign your whole org. DML is executed with the sObject Collections API and reports partial successes/failures per record.
323
327
 
324
328
  ### `sf ps export`
325
329
 
326
- Read-only. Snapshots the org's current assignments into a single YAML file you can commit and then feed back into the other commands.
330
+ Read-only. Snapshots the org's current assignments as YAML you can commit and then feed back into the other commands. Writes to a file with `--output-file`, or to stdout when that flag is omitted.
327
331
 
328
332
  ```
329
333
  USAGE
330
- $ sf ps export -o <org> --output-file <file> [--json]
334
+ $ sf ps export -o <org> [-f <file>] [--user <username>...]
335
+ [--kind <scope>...] [--json]
331
336
 
332
337
  FLAGS
333
338
  -o, --target-org=<org> (required) Org to read assignments from.
334
- --output-file=<file> (required) Path of the YAML file to write. Parent directories are created; an existing file is overwritten.
339
+ -f, --output-file=<file> Path of the YAML file to write. Parent directories are created; an existing file is overwritten. Omit to write to stdout.
340
+ --user=<username>... Only export these users. Repeatable, matched on exact username.
341
+ --kind=<scope>... Only export these scopes: permissionSets | permissionSetGroups | permissionSetLicenses. Repeatable.
335
342
  ```
336
343
 
337
344
  It exports every assignable permission set, group, and license assignment held by active users, keyed by username, so the result is immediately valid input for `check`, `validate`, `plan`, and `apply`. Profile-owned permission sets and inactive users are skipped.
338
345
 
346
+ With `--output-file` the command writes the file and prints a one-line summary. Omit the flag and the YAML goes to stdout instead, byte-for-byte identical to what the file would contain, so it pipes and diffs cleanly: in that mode only the document reaches stdout and warnings go to stderr, so there is nothing to strip. Under `--json` the envelope is the only thing on stdout, and when `--output-file` is omitted the document comes back in its `content` field.
347
+
348
+ ```bash
349
+ # Diff the org's live state against a committed snapshot
350
+ sf ps export -o prod | diff - permissions/prod.yml
351
+
352
+ # Redirect a scoped snapshot to a file of your choosing
353
+ sf ps export -o prod --user jdoe@acme.com > jdoe.yml
354
+ ```
355
+
356
+ By default the whole org is exported. `--user` and `--kind` narrow the snapshot: pass either to scope it down, and pass both to intersect (the named users, restricted to the named scopes). Values within a flag are a union, so `--user jdoe@acme.com --user asmith@acme.com` exports both. The `--kind` values are the same scope keys the file uses, so `--kind permissionSetLicenses` reads back exactly the `permissionSetLicenses:` block.
357
+
358
+ ```bash
359
+ # Snapshot one team's permission sets and groups only
360
+ sf ps export -o prod --output-file team.yml \
361
+ --user jdoe@acme.com --user asmith@acme.com \
362
+ --kind permissionSets --kind permissionSetGroups
363
+ ```
364
+
365
+ A requested `--user` that has no matching assignments (a typo, or a user who genuinely holds nothing in scope) is reported as a warning and the export continues with whoever matched, so a mistyped username never masquerades as a clean empty file.
366
+
367
+ ## GitHub Actions
368
+
369
+ Three small workflows that build on each other: check pull requests with no org at all, validate the same files against the real org, then apply on merge.
370
+
371
+ **1. Check pull requests to main** (no org, no secrets):
372
+
373
+ ```yaml
374
+ # .github/workflows/permissions-check.yml
375
+ name: permissions-check
376
+
377
+ on:
378
+ pull_request:
379
+ branches: [main]
380
+
381
+ jobs:
382
+ check:
383
+ runs-on: ubuntu-latest
384
+ steps:
385
+ - uses: actions/checkout@v7
386
+ - uses: actions/setup-node@v7
387
+ with:
388
+ node-version: 20
389
+
390
+ - name: Install Salesforce CLI
391
+ run: npm install --global @salesforce/cli
392
+
393
+ - name: Install the plugin
394
+ run: sf plugins install sf-plugin-permission-sets
395
+
396
+ - name: Check the permission files
397
+ run: sf ps check --file "permissions/*.yml"
398
+ ```
399
+
400
+ **2. Validate pull requests against the org** (needs org auth):
401
+
402
+ ```yaml
403
+ # .github/workflows/permissions-validate.yml
404
+ name: permissions-validate
405
+
406
+ on:
407
+ pull_request:
408
+ branches: [main]
409
+ paths:
410
+ - "permissions/**"
411
+
412
+ jobs:
413
+ validate:
414
+ runs-on: ubuntu-latest
415
+ steps:
416
+ - uses: actions/checkout@v7
417
+ - uses: actions/setup-node@v7
418
+ with:
419
+ node-version: 20
420
+
421
+ - name: Install Salesforce CLI
422
+ run: npm install --global @salesforce/cli
423
+
424
+ - name: Install the plugin
425
+ run: sf plugins install sf-plugin-permission-sets
426
+
427
+ - name: Log in to the org
428
+ run: echo '${{ secrets.SFDX_AUTH_URL }}' | sf org login sfdx-url --sfdx-url-stdin --alias prod
429
+
430
+ - name: Validate the permission files
431
+ run: sf ps validate --file "permissions/*.yml" --target-org prod
432
+ ```
433
+
434
+ **3. Apply on merge to main** (needs org auth):
435
+
436
+ ```yaml
437
+ # .github/workflows/permissions-apply.yml
438
+ name: permissions-apply
439
+
440
+ on:
441
+ push:
442
+ branches: [main]
443
+
444
+ jobs:
445
+ apply:
446
+ runs-on: ubuntu-latest
447
+ steps:
448
+ - uses: actions/checkout@v7
449
+ - uses: actions/setup-node@v7
450
+ with:
451
+ node-version: 20
452
+
453
+ - name: Install Salesforce CLI
454
+ run: npm install --global @salesforce/cli
455
+
456
+ - name: Install the plugin
457
+ run: sf plugins install sf-plugin-permission-sets
458
+
459
+ - name: Log in to the org
460
+ run: echo '${{ secrets.SFDX_AUTH_URL }}' | sf org login sfdx-url --sfdx-url-stdin --alias prod
461
+
462
+ - name: Apply the assignments
463
+ run: sf ps apply --file "permissions/*.yml" --target-org prod --mode sync --no-prompt
464
+ ```
465
+
466
+ Workflows 2 and 3 share one secret. Get the auth URL once with `sf org display --verbose --target-org prod`, copy the `Sfdx Auth Url` value, and save it as a repository secret named `SFDX_AUTH_URL`.
467
+
468
+ Want the diff on the PR before merging? Add a `sf ps plan --file "permissions/*.yml" --target-org prod` step to workflow 2, right after the login step, so reviewers see the change set that `apply` will carry out on merge.
469
+
339
470
  ## Inspiration & equivalents
340
471
 
341
472
  This plugin's command surface borrows ideas from tools you already know:
@@ -376,17 +507,14 @@ gh release create v0.2.0 --target main --title v0.2.0 --notes "Add ps export"
376
507
  | dist-tag | Published by | Install |
377
508
  | --- | --- | --- |
378
509
  | `latest` | manual release with a normal tag like `v1.2.0` | `sf plugins install sf-plugin-permission-sets` |
379
- | `next` | manual release with a hyphenated tag like `v1.3.0-beta.1` | `sf plugins install sf-plugin-permission-sets@next` |
380
510
  | `dev` | automatic on every push to `main` | `sf plugins install sf-plugin-permission-sets@dev` |
381
511
 
382
- The `next` tag is selected whenever the version contains a hyphen, not by GitHub's prerelease checkbox.
383
-
384
512
  ## Architecture
385
513
 
386
514
  The plugin is layered so every command reuses the same core. Commands stay thin, services hold the orchestration, core holds the reusable primitives, and a thin adapter layer isolates the Salesforce SDK.
387
515
 
388
516
  - **Commands** (`src/commands/ps/`): oclif only. They parse flags, construct the service (wiring in the org adapter when the command needs one), render output, and set the exit code.
389
- - **Services** (`src/services/`): one per command (`check`, `validate`, `export`, `apply`, and `plan`). Each is a class built from its dependencies and inputs, with a parameterless `run()` that turns the core into a command's behavior. A service also declares the ports it needs from the outside, like the `OrgClient` interface its adapter implements.
517
+ - **Services** (`src/services/`): one per command (`check`, `validate`, `export`, `apply`, and `plan`), plus `resolution`, which the org-facing ones share. Each is a class whose constructor takes only its dependencies (the org client, a confirmation callback), while the per-invocation inputs are `run()` parameters, so one instance serves any number of runs. A service also declares the ports it needs from the outside, like the `OrgClient` interface its adapter implements.
390
518
  - **Core** (`src/core/`): the reusable building blocks. Pure, with no `@salesforce/*` imports, so every piece is unit-testable on its own.
391
519
  - **Adapters** (`src/adapters/`): the boundary to the outside world. `ConnectionOrgClient` implements the `OrgClient` port (declared in services) with a Salesforce `Connection`, and owns all the SOQL and SObject detail. Services depend on the port, not the SDK, so they test against a fake and stay free of connection detail.
392
520
 
@@ -401,9 +529,33 @@ The plugin is layered so every command reuses the same core. Commands stay thin,
401
529
  | `load` | Expand globs, run parse then validate then normalize per file, and merge by union. |
402
530
  | `resolve` | Pure rules that turn declared references and the org's answers into findings, plus id lookups for assigning. No SOQL: the adapter owns that. |
403
531
  | `diff` | The desired model vs. the org's current state, producing adds, removes, and unchanged. |
532
+ | `mode` | Scope a diff to what a reconcile mode acts on, plus the drift it leaves alone. |
404
533
  | `report` | Format a diff as a plan. |
405
534
 
406
- Commands are slices of one pipeline. `check` runs the offline **load** stage only. `validate` adds **resolve**: it looks the declared references up through the `OrgClient` port (the adapter builds the SOQL) and evaluates the org's answers with resolve's pure rules. `export` runs in the opposite direction: it **fetch**es the org's current assignments through the port and **serialize**s them straight back to YAML, skipping load entirely. `apply` is the full pipeline: load, resolve to ids, **fetch** current state, **diff**, then insert and delete through the Collections API per the mode (guarded by `--max-deletes` and a confirmation). `plan` is that same pipeline stopping before the DML: load, resolve to ids, **fetch** current state, **diff**, and report, the same preview `apply --dry-run` produces.
535
+ Commands are slices of one pipeline. `check` runs the **load** stage only, with no org. `validate` adds **resolve**: it looks the declared references up through the `OrgClient` port (the adapter builds the SOQL) and evaluates the org's answers with resolve's pure rules. `export` runs in the opposite direction: it **fetch**es the org's current assignments through the port and **serialize**s them straight back to YAML, skipping load entirely. `apply` is the full pipeline: load, resolve to ids, **fetch** current state, **diff**, then insert and delete through the Collections API per the mode (guarded by `--max-deletes` and a confirmation). `plan` is that same pipeline stopping before the DML: load, resolve to ids, **fetch** current state, **diff**, and report, the same preview `apply --dry-run` produces.
536
+
537
+ ## Development
538
+
539
+ ```bash
540
+ npm ci
541
+ npm run build # compile and lint
542
+ npm test # compile, then run the suite
543
+ ```
544
+
545
+ The suite is black-box: every spec spawns the real `sf ps ...` binary. Install the Salesforce CLI first (`npm install -g @salesforce/cli`). The test run links this plugin into `sf` before the first spec and unlinks it at the end.
546
+
547
+ ### Test environment
548
+
549
+ Tests take their parameters from the environment, never from a committed file. Copy the template and fill it in:
550
+
551
+ ```bash
552
+ cp .env.example .env
553
+ ```
554
+
555
+ | Variable | Required | What it is |
556
+ | --- | --- | --- |
557
+ | `PS_TARGET_ORG` | yes | Username or alias of an already-authenticated org. The `plan`, `apply`, and `export` specs run against it. |
558
+
407
559
 
408
560
  ## License
409
561
 
@@ -1,14 +1,20 @@
1
1
  import { Connection } from '@salesforce/core';
2
- import { ActualAssignment, AssignmentOutcome, AssignmentUpdate, DesiredAssignment, Kind, OrgTarget, OrgUser, ResolvedAddition, TargetRef } from '../core/model.js';
3
- import { OrgClient } from '../services/adapters/org-client.js';
2
+ import { ActualAssignment, AssignmentFilter, AssignmentOutcome, AssignmentUpdate, DesiredAssignment, OrgTarget, OrgUser, ResolvedAddition, TargetRef } from '../core/index.js';
3
+ import { OrgClient } from '../services/adapters/index.js';
4
4
  /** Adapter backing OrgClient with a Salesforce Connection. autoFetchQuery pages past 2000 rows. */
5
5
  export declare class ConnectionOrgClient implements OrgClient {
6
6
  private readonly connection;
7
7
  constructor(connection: Connection);
8
8
  findUsers(usernames: string[]): Promise<OrgUser[]>;
9
- findTargets(kind: Kind, names: string[]): Promise<OrgTarget[]>;
10
- listAssignments(): Promise<DesiredAssignment[]>;
11
- currentAssignments(targets: TargetRef[]): Promise<ActualAssignment[]>;
9
+ findPermissionSets(names: string[]): Promise<OrgTarget[]>;
10
+ findPermissionSetGroups(names: string[]): Promise<OrgTarget[]>;
11
+ findPermissionSetLicenses(names: string[]): Promise<OrgTarget[]>;
12
+ listAssignments(filter?: AssignmentFilter): Promise<DesiredAssignment[]>;
13
+ private listMemberships;
14
+ private listLicenses;
15
+ listCurrentAssignments(targets: TargetRef[]): Promise<ActualAssignment[]>;
16
+ private listMembershipAssignments;
17
+ private listLicenseAssignments;
12
18
  addAssignments(additions: ResolvedAddition[]): Promise<AssignmentOutcome[]>;
13
19
  updateAssignments(updates: AssignmentUpdate[]): Promise<AssignmentOutcome[]>;
14
20
  removeAssignments(removals: ActualAssignment[]): Promise<AssignmentOutcome[]>;
@@ -1 +1 @@
1
- {"version":3,"file":"connection-org-client.d.ts","sourceRoot":"","sources":["../../src/adapters/connection-org-client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAC9C,OAAO,EACH,gBAAgB,EAChB,iBAAiB,EACjB,gBAAgB,EAChB,iBAAiB,EACjB,IAAI,EACJ,SAAS,EACT,OAAO,EACP,gBAAgB,EAChB,SAAS,EACZ,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,SAAS,EAAE,MAAM,oCAAoC,CAAC;AA6J/D,mGAAmG;AACnG,qBAAa,mBAAoB,YAAW,SAAS;IAC9B,OAAO,CAAC,QAAQ,CAAC,UAAU;gBAAV,UAAU,EAAE,UAAU;IAE7C,SAAS,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC;IAQlD,WAAW,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC;IAS9D,eAAe,IAAI,OAAO,CAAC,iBAAiB,EAAE,CAAC;IA8C/C,kBAAkB,CAAC,OAAO,EAAE,SAAS,EAAE,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC;IA0DrE,cAAc,CAAC,SAAS,EAAE,gBAAgB,EAAE,GAAG,OAAO,CAAC,iBAAiB,EAAE,CAAC;IAoB3E,iBAAiB,CAAC,OAAO,EAAE,gBAAgB,EAAE,GAAG,OAAO,CAAC,iBAAiB,EAAE,CAAC;IAoB5E,iBAAiB,CAAC,QAAQ,EAAE,gBAAgB,EAAE,GAAG,OAAO,CAAC,iBAAiB,EAAE,CAAC;YAuB5E,KAAK;CAItB"}
1
+ {"version":3,"file":"connection-org-client.d.ts","sourceRoot":"","sources":["../../src/adapters/connection-org-client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAC9C,OAAO,EACH,gBAAgB,EAChB,gBAAgB,EAChB,iBAAiB,EACjB,gBAAgB,EAChB,iBAAiB,EAEjB,SAAS,EACT,OAAO,EACP,gBAAgB,EAChB,SAAS,EACZ,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,SAAS,EAAE,MAAM,+BAA+B,CAAC;AAgR1D,mGAAmG;AACnG,qBAAa,mBAAoB,YAAW,SAAS;IAC9B,OAAO,CAAC,QAAQ,CAAC,UAAU;gBAAV,UAAU,EAAE,UAAU;IAE7C,SAAS,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC;IAMlD,kBAAkB,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC;IAMzD,uBAAuB,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC;IAM9D,yBAAyB,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC;IAMhE,eAAe,CAAC,MAAM,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC,iBAAiB,EAAE,CAAC;YAkBvE,eAAe;YAoBf,YAAY;IAYb,sBAAsB,CAAC,OAAO,EAAE,SAAS,EAAE,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC;YAqBxE,yBAAyB;YAezB,sBAAsB;IAWvB,cAAc,CAAC,SAAS,EAAE,gBAAgB,EAAE,GAAG,OAAO,CAAC,iBAAiB,EAAE,CAAC;IAkB3E,iBAAiB,CAAC,OAAO,EAAE,gBAAgB,EAAE,GAAG,OAAO,CAAC,iBAAiB,EAAE,CAAC;IAkB5E,iBAAiB,CAAC,QAAQ,EAAE,gBAAgB,EAAE,GAAG,OAAO,CAAC,iBAAiB,EAAE,CAAC;YAmB5E,KAAK;CAItB"}