sf-plugin-permission-sets 0.4.0 → 0.6.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 (96) hide show
  1. package/README.md +135 -79
  2. package/lib/adapters/connection-org-client.d.ts +8 -6
  3. package/lib/adapters/connection-org-client.d.ts.map +1 -1
  4. package/lib/adapters/connection-org-client.js +187 -96
  5. package/lib/adapters/connection-org-client.js.map +1 -1
  6. package/lib/commands/ps/apply.d.ts +1 -15
  7. package/lib/commands/ps/apply.d.ts.map +1 -1
  8. package/lib/commands/ps/apply.js +11 -81
  9. package/lib/commands/ps/apply.js.map +1 -1
  10. package/lib/commands/ps/check.d.ts.map +1 -1
  11. package/lib/commands/ps/check.js +3 -3
  12. package/lib/commands/ps/check.js.map +1 -1
  13. package/lib/commands/ps/export.js +2 -2
  14. package/lib/commands/ps/export.js.map +1 -1
  15. package/lib/commands/ps/plan.d.ts +2 -8
  16. package/lib/commands/ps/plan.d.ts.map +1 -1
  17. package/lib/commands/ps/plan.js +31 -84
  18. package/lib/commands/ps/plan.js.map +1 -1
  19. package/lib/commands/ps/validate.d.ts.map +1 -1
  20. package/lib/commands/ps/validate.js +3 -3
  21. package/lib/commands/ps/validate.js.map +1 -1
  22. package/lib/core/diff.d.ts +37 -1
  23. package/lib/core/diff.d.ts.map +1 -1
  24. package/lib/core/diff.js +68 -4
  25. package/lib/core/diff.js.map +1 -1
  26. package/lib/core/finding.d.ts +9 -7
  27. package/lib/core/finding.d.ts.map +1 -1
  28. package/lib/core/finding.js +16 -8
  29. package/lib/core/finding.js.map +1 -1
  30. package/lib/core/index.d.ts +4 -4
  31. package/lib/core/index.d.ts.map +1 -1
  32. package/lib/core/index.js +3 -3
  33. package/lib/core/index.js.map +1 -1
  34. package/lib/core/load.js +1 -1
  35. package/lib/core/load.js.map +1 -1
  36. package/lib/core/model.d.ts +24 -25
  37. package/lib/core/model.d.ts.map +1 -1
  38. package/lib/core/normalize.d.ts.map +1 -1
  39. package/lib/core/normalize.js +13 -8
  40. package/lib/core/normalize.js.map +1 -1
  41. package/lib/core/report.d.ts +2 -1
  42. package/lib/core/report.d.ts.map +1 -1
  43. package/lib/core/report.js +30 -17
  44. package/lib/core/report.js.map +1 -1
  45. package/lib/core/resolve.d.ts +8 -6
  46. package/lib/core/resolve.d.ts.map +1 -1
  47. package/lib/core/resolve.js +18 -14
  48. package/lib/core/resolve.js.map +1 -1
  49. package/lib/core/serialize.d.ts.map +1 -1
  50. package/lib/core/serialize.js +29 -13
  51. package/lib/core/serialize.js.map +1 -1
  52. package/lib/core/target-name.d.ts +20 -0
  53. package/lib/core/target-name.d.ts.map +1 -0
  54. package/lib/core/target-name.js +32 -0
  55. package/lib/core/target-name.js.map +1 -0
  56. package/lib/core/username.d.ts +23 -0
  57. package/lib/core/username.d.ts.map +1 -0
  58. package/lib/core/username.js +35 -0
  59. package/lib/core/username.js.map +1 -0
  60. package/lib/services/adapters/org-client.d.ts +9 -5
  61. package/lib/services/adapters/org-client.d.ts.map +1 -1
  62. package/lib/services/apply.d.ts +1 -18
  63. package/lib/services/apply.d.ts.map +1 -1
  64. package/lib/services/apply.js +9 -38
  65. package/lib/services/apply.js.map +1 -1
  66. package/lib/services/export.d.ts.map +1 -1
  67. package/lib/services/export.js +3 -2
  68. package/lib/services/export.js.map +1 -1
  69. package/lib/services/index.d.ts +0 -1
  70. package/lib/services/index.d.ts.map +1 -1
  71. package/lib/services/index.js +0 -1
  72. package/lib/services/index.js.map +1 -1
  73. package/lib/services/plan.d.ts +4 -14
  74. package/lib/services/plan.d.ts.map +1 -1
  75. package/lib/services/plan.js +9 -22
  76. package/lib/services/plan.js.map +1 -1
  77. package/lib/services/resolution.d.ts +17 -9
  78. package/lib/services/resolution.d.ts.map +1 -1
  79. package/lib/services/resolution.js +43 -16
  80. package/lib/services/resolution.js.map +1 -1
  81. package/lib/services/validate.d.ts +2 -1
  82. package/lib/services/validate.d.ts.map +1 -1
  83. package/lib/services/validate.js +10 -1
  84. package/lib/services/validate.js.map +1 -1
  85. package/messages/ps.apply.md +2 -34
  86. package/messages/ps.plan.md +0 -8
  87. package/messages/ps.validate.md +1 -1
  88. package/package.json +10 -6
  89. package/lib/core/mode.d.ts +0 -18
  90. package/lib/core/mode.d.ts.map +0 -1
  91. package/lib/core/mode.js +0 -16
  92. package/lib/core/mode.js.map +0 -1
  93. package/lib/core/plan-file.d.ts +0 -61
  94. package/lib/core/plan-file.d.ts.map +0 -1
  95. package/lib/core/plan-file.js +0 -74
  96. package/lib/core/plan-file.js.map +0 -1
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.
@@ -15,6 +15,7 @@ Stop clicking through Setup to grant access. Commit a YAML file, open a PR, let
15
15
 
16
16
  - [Why](#why)
17
17
  - [Install](#install)
18
+ - [Org permissions](#org-permissions)
18
19
  - [Quick start](#quick-start)
19
20
  - [Permission files](#permission-files)
20
21
  - [Organizing files](#organizing-files)
@@ -22,9 +23,10 @@ Stop clicking through Setup to grant access. Commit a YAML file, open a PR, let
22
23
  - [Validations](#validations)
23
24
  - [Commands](#commands)
24
25
  - [GitHub Actions](#github-actions)
25
- - [Inspiration & equivalents](#inspiration--equivalents)
26
26
  - [Versioning](#versioning)
27
27
  - [Architecture](#architecture)
28
+ - [Development](#development)
29
+ - [License](#license)
28
30
 
29
31
  ---
30
32
 
@@ -54,28 +56,47 @@ Or pin a version:
54
56
  sf plugins install sf-plugin-permission-sets@x.y.z
55
57
  ```
56
58
 
57
- Requires Salesforce CLI (`sf`) and Node.js 20+.
59
+ Requires Salesforce CLI (`sf`) and Node.js 22.13+.
60
+
61
+ ## Org permissions
62
+
63
+ What the user behind `--target-org` needs:
64
+
65
+ | Command | API Enabled | View Setup and Configuration | View Roles and Role Hierarchy | Assign Permission Sets |
66
+ | --- | :---: | :---: | :---: | :---: |
67
+ | `sf ps check` | - | - | - | - |
68
+ | `sf ps validate` | ✓ | ✓ | ✓ | - |
69
+ | `sf ps plan` | ✓ | ✓ | ✓ | - |
70
+ | `sf ps export` | ✓ | ✓ | ✓ | - |
71
+ | `sf ps apply` | ✓ | ✓ | ✓ | ✓ |
72
+
73
+ ✓ Required, - Not required. `Manage Users` is not required, not even for permission set licenses.
74
+
75
+ Two permission sets, so a pull request job cannot change the org:
76
+
77
+ - [PS_Plugin_Read](setup/permissionsets/PS_Plugin_Read.permissionset-meta.xml): reads permission set assignments.
78
+ - [PS_Plugin_Write](setup/permissionsets/PS_Plugin_Write.permissionset-meta.xml): reads and modifies permission set assignments.
58
79
 
59
80
  ## Quick start
60
81
 
61
82
  ```bash
62
83
  # 1. Bootstrap YAML from an existing org (so you don't start from scratch)
63
- sf ps export --target-org dev --output-file permissions.yml
84
+ sf ps export --target-org dev --output-file permissions/dev.yml
64
85
 
65
86
  # 2. Edit the files, commit, open a PR. Check them, no org needed:
66
- sf ps check --file "./permissions/*.yml"
87
+ sf ps check --file "permissions/*.yml"
67
88
 
68
89
  # 3. Validate against a real org (do the users/permission sets exist?)
69
- sf ps validate --file "./permissions/*.yml" --target-org dev
90
+ sf ps validate --file "permissions/*.yml" --target-org dev
70
91
 
71
92
  # 4. See what would change
72
- sf ps plan --file "./permissions/*.yml" --target-org dev
93
+ sf ps plan --file "permissions/*.yml" --target-org dev
73
94
 
74
95
  # 5. Apply it (additive by default, only adds)
75
- sf ps apply --file "./permissions/*.yml" --target-org dev
96
+ sf ps apply --file "permissions/*.yml" --target-org dev
76
97
 
77
98
  # 6. Full reconcile, including removals (opt-in)
78
- sf ps apply --file "./permissions/*.yml" --target-org prod --mode sync
99
+ sf ps apply --file "permissions/*.yml" --target-org prod --mode sync
79
100
  ```
80
101
 
81
102
  ## Permission files
@@ -115,6 +136,12 @@ users:
115
136
  - Sales_Manager
116
137
  ```
117
138
 
139
+ ### Names are matched case-insensitively
140
+
141
+ Salesforce compares usernames and API names without regard to case, and so does this plugin. `JDoe@acme.com` and `jdoe@acme.com` are the same user, `Sales_Manager` and `sales_manager` are the same permission set, and every comparison the plugin makes (merging files, de-duplicating, diffing against the org, filtering an export) follows that rule. The spelling you write is the spelling that gets displayed and written back, so a file is never rewritten just to normalize case.
142
+
143
+ The practical consequence is that declaring the same user twice under different spellings merges the two blocks rather than creating two users, and listing a target twice under different spellings is the same duplicate `check` already warns about.
144
+
118
145
  ### Timed access (expiration)
119
146
 
120
147
  A permission set or permission set group entry can be a plain name or an object with an `expiration`. The expiration is an ISO 8601 datetime, and Salesforce removes access automatically when it passes. Plain names never expire.
@@ -197,7 +224,8 @@ Every run checks the files first. `check` runs the file checks with no org, and
197
224
  | Situation | Checked by | Severity | Result |
198
225
  | --- | --- | :---: | --- |
199
226
  | Same user in two files with different targets | `check` | ✅ ok | Merged into one model, the point of slicing |
200
- | Same target listed twice for a user | `check` | ⚠️ warning | Deduped |
227
+ | Same user under two spellings that differ only in case | `check` | ok | Merged into one user, matching how the org compares usernames |
228
+ | Same target listed twice for a user (case-insensitively) | `check` | ⚠️ warning | Deduped |
201
229
  | A user with no scopes, or an empty list | `check` | ⚠️ warning | Ignored as a no-op |
202
230
  | Same username key appears twice in one file | `check` | ❌ error | Rejected, the intent is ambiguous |
203
231
  | Declared user, permission set, group, or license missing or not unique | `validate` | ❌ error | Run fails before any change |
@@ -209,8 +237,8 @@ Every run checks the files first. `check` runs the file checks with no org, and
209
237
  | `sf ps check` | Static analysis of the files alone: schema, duplicates, conflicts, identifier shape. No org, no auth. |
210
238
  | `sf ps validate` | Everything `check` does, plus resolving every user/permission set against the org. |
211
239
  | `sf ps plan` | Compute and display the change set: a read-only preview of what `apply` would do. |
212
- | `sf ps apply` | Reconcile the org. Honors `--mode`, prompts before deletes, enforces guardrails. |
213
240
  | `sf ps export` | Generate YAML from the current org state to bootstrap adoption. |
241
+ | `sf ps apply` | Reconcile the org. Honors `--mode`, prompts before deletes, enforces guardrails. |
214
242
 
215
243
  ### `sf ps check`
216
244
 
@@ -250,19 +278,15 @@ group, and license referenced actually exists and resolves uniquely.
250
278
 
251
279
  ```
252
280
  USAGE
253
- $ sf ps plan -o <org> -f <glob>... [--mode <value>] [--show-unchanged]
254
- [--out <file>] [--json]
281
+ $ sf ps plan -o <org> -f <glob>... [--mode <value>] [--show-unchanged] [--json]
255
282
 
256
283
  FLAGS
257
284
  -o, --target-org=<org> (required)
258
285
  -f, --file=<glob>... (required) YAML file(s) to read. Repeatable, globs expanded by the plugin.
259
286
  --mode=<value> additive | destructive | sync [default: additive]
260
287
  --show-unchanged List assignments that already match, instead of only counting them.
261
- --out=<file> Write the computed change set to a plan file that `apply` can run verbatim.
262
288
  ```
263
289
 
264
- With `--out` the plan is also saved to a file: the resolved change set (adds, updates, removes with their record ids), the mode it was computed for, and the org it targets. Feed that file to `apply --plan` to execute exactly what you reviewed, with no recomputation. See [Saved plans](#saved-plans).
265
-
266
290
  The body shows only what the mode will do, and unchanged assignments are summarized as a count (pass `--show-unchanged` to list them). The default `additive` run previews only what it grants, and reports the undeclared assignment it won't remove as drift:
267
291
 
268
292
  ```text
@@ -307,46 +331,6 @@ Unchanged: 4 assignments (--show-unchanged to list).
307
331
  Next: sf ps apply -o prod -f "permissions/*.yml" --mode sync
308
332
  ```
309
333
 
310
- ### `sf ps apply`
311
-
312
- ```
313
- USAGE
314
- $ sf ps apply -o <org> (-f <glob>... | --plan <file>) [--mode <value>]
315
- [--max-deletes <n>] [--dry-run] [--show-unchanged] [--no-prompt] [--json]
316
-
317
- FLAGS
318
- -o, --target-org=<org> (required)
319
- -f, --file=<glob>... YAML file(s) to read. Repeatable, globs expanded by the plugin.
320
- --plan=<file> Apply a saved plan file from `plan --out` instead of re-reading YAML.
321
- --mode=<value> additive | destructive | sync [default: additive]
322
- --max-deletes=<n> Abort if a run would remove more than n assignments. [default: 50]
323
- --dry-run Resolve and diff, print what would happen, change nothing.
324
- --show-unchanged List assignments that already match, instead of only counting them.
325
- --no-prompt Skip the deletion confirmation prompt (for CI).
326
- ```
327
-
328
- Provide exactly one source: `--file` (read and diff the YAML now) or `--plan` (run a saved plan). They cannot be combined, and `--mode` cannot accompany `--plan` (the plan already carries its mode). `--max-deletes`, `--dry-run`, and `--no-prompt` apply to both.
329
-
330
- 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.
331
-
332
- #### Saved plans
333
-
334
- `plan --out` and `apply --plan` split review from execution, so what you approve is exactly what runs:
335
-
336
- ```bash
337
- sf ps plan -o prod -f "permissions/*.yml" --mode sync --out prod.plan
338
- # ... review prod.plan, get sign-off ...
339
- sf ps apply -o prod --plan prod.plan
340
- ```
341
-
342
- Without a saved plan, `apply` recomputes from the files: it re-reads the YAML, re-resolves every reference to an org id, and re-diffs against live state. Anything that changed since you ran `plan` (an edited file, a renamed permission set, another admin's assignment) silently changes what `apply` does. A saved plan freezes the resolved change set, so `apply --plan` executes those exact records with no recomputation.
343
-
344
- Guardrails:
345
-
346
- - `apply --plan` refuses a plan built for a different org (the plan records the org id).
347
- - It refuses a plan file it cannot parse or whose format version it does not recognize.
348
- - The plan is executed as recorded. If the org drifted after the plan was written, individual records may fail (a removed target, an already-deleted assignment); those surface as per-record failures in the outcome report rather than aborting the run. Re-run `plan` to get a fresh plan when in doubt.
349
-
350
334
  ### `sf ps export`
351
335
 
352
336
  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.
@@ -359,7 +343,7 @@ USAGE
359
343
  FLAGS
360
344
  -o, --target-org=<org> (required) Org to read assignments from.
361
345
  -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.
362
- --user=<username>... Only export these users. Repeatable, matched on exact username.
346
+ --user=<username>... Only export these users. Repeatable, matched case-insensitively.
363
347
  --kind=<scope>... Only export these scopes: permissionSets | permissionSetGroups | permissionSetLicenses. Repeatable.
364
348
  ```
365
349
 
@@ -375,7 +359,7 @@ sf ps export -o prod | diff - permissions/prod.yml
375
359
  sf ps export -o prod --user jdoe@acme.com > jdoe.yml
376
360
  ```
377
361
 
378
- 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.
362
+ 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. A `--user` value is matched case-insensitively, so only a user the org really does not hold assignments for is reported as unmatched. The `--kind` values are the same scope keys the file uses, so `--kind permissionSetLicenses` reads back exactly the `permissionSetLicenses:` block.
379
363
 
380
364
  ```bash
381
365
  # Snapshot one team's permission sets and groups only
@@ -386,9 +370,35 @@ sf ps export -o prod --output-file team.yml \
386
370
 
387
371
  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.
388
372
 
373
+ ### `sf ps apply`
374
+
375
+ ```
376
+ USAGE
377
+ $ sf ps apply -o <org> -f <glob>... [--mode <value>]
378
+ [--max-deletes <n>] [--dry-run] [--show-unchanged] [--no-prompt] [--json]
379
+
380
+ FLAGS
381
+ -o, --target-org=<org> (required)
382
+ -f, --file=<glob>... (required) YAML file(s) to read. Repeatable, globs expanded by the plugin.
383
+ --mode=<value> additive | destructive | sync [default: additive]
384
+ --max-deletes=<n> Abort if a run would remove more than n assignments. [default: 50]
385
+ --dry-run Resolve and diff, print what would happen, change nothing.
386
+ --show-unchanged List assignments that already match, instead of only counting them.
387
+ --no-prompt Skip the deletion confirmation prompt (for CI).
388
+ ```
389
+
390
+ `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).
391
+
392
+ > [!CAUTION]
393
+ > `--mode sync` and `--mode destructive` revoke access. 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.
394
+
395
+ DML is executed with the sObject Collections API and reports partial successes/failures per record.
396
+
389
397
  ## GitHub Actions
390
398
 
391
- Two dead-simple workflows: check pull requests to main with no org, then apply on merge.
399
+ 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.
400
+
401
+ Point the pull request workflows at a `PS_Plugin_Read` user and only the merge workflow at `PS_Plugin_Write` (see [Org permissions](#org-permissions)).
392
402
 
393
403
  **1. Check pull requests to main** (no org, no secrets):
394
404
 
@@ -407,7 +417,7 @@ jobs:
407
417
  - uses: actions/checkout@v7
408
418
  - uses: actions/setup-node@v7
409
419
  with:
410
- node-version: 20
420
+ node-version: 22
411
421
 
412
422
  - name: Install Salesforce CLI
413
423
  run: npm install --global @salesforce/cli
@@ -419,7 +429,41 @@ jobs:
419
429
  run: sf ps check --file "permissions/*.yml"
420
430
  ```
421
431
 
422
- **2. Apply on merge to main** (needs org auth):
432
+ **2. Validate pull requests against the org** (needs org auth):
433
+
434
+ ```yaml
435
+ # .github/workflows/permissions-validate.yml
436
+ name: permissions-validate
437
+
438
+ on:
439
+ pull_request:
440
+ branches: [main]
441
+ paths:
442
+ - "permissions/**"
443
+
444
+ jobs:
445
+ validate:
446
+ runs-on: ubuntu-latest
447
+ steps:
448
+ - uses: actions/checkout@v7
449
+ - uses: actions/setup-node@v7
450
+ with:
451
+ node-version: 22
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: Validate the permission files
463
+ run: sf ps validate --file "permissions/*.yml" --target-org prod
464
+ ```
465
+
466
+ **3. Apply on merge to main** (needs org auth):
423
467
 
424
468
  ```yaml
425
469
  # .github/workflows/permissions-apply.yml
@@ -436,7 +480,7 @@ jobs:
436
480
  - uses: actions/checkout@v7
437
481
  - uses: actions/setup-node@v7
438
482
  with:
439
- node-version: 20
483
+ node-version: 22
440
484
 
441
485
  - name: Install Salesforce CLI
442
486
  run: npm install --global @salesforce/cli
@@ -451,18 +495,9 @@ jobs:
451
495
  run: sf ps apply --file "permissions/*.yml" --target-org prod --mode sync --no-prompt
452
496
  ```
453
497
 
454
- 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`.
455
-
456
- Want the diff on the PR before merging? Add a `sf ps plan --file "permissions/*.yml" --target-org prod` step (it needs the same org auth) to the check workflow.
457
-
458
- ## Inspiration & equivalents
459
-
460
- This plugin's command surface borrows ideas from tools you already know:
498
+ 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`.
461
499
 
462
- - [Terraform](https://developer.hashicorp.com/terraform/docs)
463
- - [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/)
464
- - [AWS SAM](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/)
465
- - [Salesforce CLI](https://developer.salesforce.com/docs/atlas.en-us.sfdx_cli_reference.meta/sfdx_cli_reference/cli_reference.htm)
500
+ 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.
466
501
 
467
502
  ## Versioning
468
503
 
@@ -502,13 +537,14 @@ gh release create v0.2.0 --target main --title v0.2.0 --notes "Add ps export"
502
537
  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.
503
538
 
504
539
  - **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.
505
- - **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.
540
+ - **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.
506
541
  - **Core** (`src/core/`): the reusable building blocks. Pure, with no `@salesforce/*` imports, so every piece is unit-testable on its own.
507
542
  - **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.
508
543
 
509
544
  | Core module | Responsibility |
510
545
  | --- | --- |
511
- | `model` | Shared domain types (assignment, org, diff). |
546
+ | `model` | Shared domain types (assignment, org). |
547
+ | `username`, `target-name` | The identifiers, owning the org's case-insensitive comparison so no caller has to remember it. |
512
548
  | `finding` | The finding type and code vocabulary, plus constructors, formatting, and counting. |
513
549
  | `schema` | The zod contract for a file, plus validation. |
514
550
  | `parse` | File text to an object, with YAML and duplicate-key errors. |
@@ -516,12 +552,32 @@ The plugin is layered so every command reuses the same core. Commands stay thin,
516
552
  | `serialize` | Canonical tuples back to a user-keyed YAML document (the inverse of `normalize`). |
517
553
  | `load` | Expand globs, run parse then validate then normalize per file, and merge by union. |
518
554
  | `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. |
519
- | `diff` | The desired model vs. the org's current state, producing adds, removes, and unchanged. |
520
- | `mode` | Scope a diff to what a reconcile mode acts on, plus the drift it leaves alone. |
555
+ | `diff` | The desired model vs. the org's current state, producing adds, removes, and unchanged. The `Diff` it returns also scopes itself to a reconcile mode, reporting what that mode acts on and the drift it leaves alone. |
521
556
  | `report` | Format a diff as a plan. |
522
- | `plan-file` | Serialize and parse a saved plan: the frozen change set `apply --plan` runs. |
523
557
 
524
- 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. `plan --out` freezes that resolved change set to a file, and `apply --plan` runs it verbatim, skipping load, resolve, and diff.
558
+ 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.
559
+
560
+ ## Development
561
+
562
+ ```bash
563
+ npm ci
564
+ npm run build # compile and lint
565
+ npm test # compile, then run the suite
566
+ ```
567
+
568
+ 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.
569
+
570
+ ### Test environment
571
+
572
+ Tests take their parameters from the environment, never from a committed file. Copy the template and fill it in:
573
+
574
+ ```bash
575
+ cp .env.example .env
576
+ ```
577
+
578
+ | Variable | Required | What it is |
579
+ | --- | --- | --- |
580
+ | `PS_TARGET_ORG` | yes | Username or alias of an already-authenticated org. The `plan`, `apply`, and `export` specs run against it. |
525
581
 
526
582
  ## License
527
583
 
@@ -1,18 +1,20 @@
1
1
  import { Connection } from '@salesforce/core';
2
- import { ActualAssignment, AssignmentFilter, AssignmentOutcome, AssignmentUpdate, DesiredAssignment, Kind, OrgTarget, OrgUser, ResolvedAddition, TargetRef } from '../core/index.js';
2
+ import { ActualAssignment, AssignmentFilter, AssignmentOutcome, AssignmentUpdate, DesiredAssignment, OrgTarget, OrgUser, ResolvedAddition, TargetName, TargetRef, Username } from '../core/index.js';
3
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
- findUsers(usernames: string[]): Promise<OrgUser[]>;
9
- findTargets(kind: Kind, names: string[]): Promise<OrgTarget[]>;
8
+ findUsers(usernames: Username[]): Promise<OrgUser[]>;
9
+ findPermissionSets(names: TargetName[]): Promise<OrgTarget[]>;
10
+ findPermissionSetGroups(names: TargetName[]): Promise<OrgTarget[]>;
11
+ findPermissionSetLicenses(names: TargetName[]): Promise<OrgTarget[]>;
10
12
  listAssignments(filter?: AssignmentFilter): Promise<DesiredAssignment[]>;
11
13
  private listMemberships;
12
14
  private listLicenses;
13
- currentAssignments(targets: TargetRef[]): Promise<ActualAssignment[]>;
14
- private membershipAssignments;
15
- private licenseAssignments;
15
+ listCurrentAssignments(targets: TargetRef[]): Promise<ActualAssignment[]>;
16
+ private listMembershipAssignments;
17
+ private listLicenseAssignments;
16
18
  addAssignments(additions: ResolvedAddition[]): Promise<AssignmentOutcome[]>;
17
19
  updateAssignments(updates: AssignmentUpdate[]): Promise<AssignmentOutcome[]>;
18
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,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,+BAA+B,CAAC;AA6J1D,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,CAAC,MAAM,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC,iBAAiB,EAAE,CAAC;YAkBvE,eAAe;YAoCf,YAAY;IAgBb,kBAAkB,CAAC,OAAO,EAAE,SAAS,EAAE,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC;YA4BpE,qBAAqB;YAsBrB,kBAAkB;IAUnB,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"}
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,UAAU,EACV,SAAS,EACT,QAAQ,EACX,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,SAAS,EAAE,MAAM,+BAA+B,CAAC;AA4R1D,mGAAmG;AACnG,qBAAa,mBAAoB,YAAW,SAAS;IAC9B,OAAO,CAAC,QAAQ,CAAC,UAAU;gBAAV,UAAU,EAAE,UAAU;IAE7C,SAAS,CAAC,SAAS,EAAE,QAAQ,EAAE,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC;IAUpD,kBAAkB,CAAC,KAAK,EAAE,UAAU,EAAE,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC;IAM7D,uBAAuB,CAAC,KAAK,EAAE,UAAU,EAAE,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC;IAMlE,yBAAyB,CAAC,KAAK,EAAE,UAAU,EAAE,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC;IAMpE,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"}