sf-plugin-permission-sets 0.0.0-dev.10 → 0.0.0-dev.12

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.
package/README.md CHANGED
@@ -19,7 +19,6 @@ Stop clicking through Setup to grant access. Commit a YAML file, open a PR, let
19
19
  - [Validations](#validations)
20
20
  - [Modes](#modes)
21
21
  - [Commands](#commands)
22
- - [CI/CD](#cicd)
23
22
  - [Inspiration & equivalents](#inspiration--equivalents)
24
23
  - [Versioning](#versioning)
25
24
 
@@ -27,7 +26,7 @@ Stop clicking through Setup to grant access. Commit a YAML file, open a PR, let
27
26
 
28
27
  ## Why
29
28
 
30
- Permission set assignments drift. People get access for a project and keep it forever. Offboarding misses a set. Nobody can answer "who can see X and why?" without a SOQL spelunking session.
29
+ Permission set assignments drift. People get access for a project and keep it forever. Offboarding misses a set. Nobody can answer "who can see X and why?" without a SOQL spelunking session. And in higher environments those grants happen by hand in Setup, with no review and no trail.
31
30
 
32
31
  This plugin makes the desired state **declarative and reviewable**:
33
32
 
@@ -36,6 +35,8 @@ This plugin makes the desired state **declarative and reviewable**:
36
35
  - ✅ **Safe by default:** deletions are opt-in and guarded by a delete threshold.
37
36
  - ✅ **CI-native:** fully offline `check`, exit codes for gating, and `--json` on every command.
38
37
  - ✅ **Flexible at the edges:** pick your file layout (by permission set or by user) and your sync mode.
38
+ - ✅ **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.
39
+ - ✅ **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.
39
40
 
40
41
  ## Install
41
42
 
@@ -195,29 +196,33 @@ Fully offline: runs in any CI job or pre-commit hook without org credentials.
195
196
 
196
197
  ```
197
198
  USAGE
198
- $ sf ps check -f <glob>... [--strict] [--json]
199
+ $ sf ps check -f <glob>... [--strict] [--watch] [--json]
199
200
 
200
201
  FLAGS
201
202
  -f, --file=<glob>... (required) YAML file(s) to read. Repeatable, globs are expanded by the plugin.
202
203
  --strict Treat warnings as errors.
204
+ -w, --watch Re-run on every change to a matched file. Stays in the foreground until you stop it (Ctrl-C).
203
205
 
204
206
  CHECKS
205
- valid YAML & schema (unknown keys rejected)
206
- duplicate assignees / duplicate (user, target) pairs
207
- conflicting intent across files
208
- empty or malformed assignee usernames
209
- internal referential integrity
207
+ - valid YAML & schema (unknown keys rejected)
208
+ - duplicate assignees / duplicate (user, target) pairs
209
+ - conflicting intent across files
210
+ - empty or malformed assignee usernames
211
+ - internal referential integrity
210
212
  ```
211
213
 
214
+ `--watch` is for the local edit loop: leave it running while you tweak the YAML and the findings refresh on every save. It re-expands the globs each run, so files you add or delete are picked up too. It's a foreground, interactive mode, so it can't be combined with `--json` and isn't meant for CI, where a single gated `check` run is what you want.
215
+
212
216
  ### `sf ps validate`
213
217
 
214
218
  ```
215
219
  USAGE
216
- $ sf ps validate -o <org> -f <glob>... [--json]
220
+ $ sf ps validate -o <org> -f <glob>... [--watch] [--json]
217
221
 
218
222
  FLAGS
219
223
  -o, --target-org=<org> (required) Org to resolve against.
220
224
  -f, --file=<glob>... (required) YAML file(s) to read. Repeatable, globs expanded by the plugin.
225
+ -w, --watch Re-run on every change to a matched file. Read-only, but each run queries the org, so point it at a scratch or dev org.
221
226
 
222
227
  Runs all offline checks, then verifies that every user (active), permission set,
223
228
  group, and license referenced actually exists and resolves uniquely.
@@ -227,13 +232,14 @@ group, and license referenced actually exists and resolves uniquely.
227
232
 
228
233
  ```
229
234
  USAGE
230
- $ sf ps plan -o <org> -f <glob>... [--mode <value>] [--fail-on-drift] [--json]
235
+ $ sf ps plan -o <org> -f <glob>... [--mode <value>] [--fail-on-drift] [--watch] [--json]
231
236
 
232
237
  FLAGS
233
238
  -o, --target-org=<org> (required)
234
239
  -f, --file=<glob>... (required) YAML file(s) to read. Repeatable, globs expanded by the plugin.
235
240
  --mode=<value> additive | destructive | sync [default: additive]
236
241
  --fail-on-drift Exit non-zero if any change is pending (for CI gates).
242
+ -w, --watch Re-diff against the org on every change to a matched file. Read-only (never applies), but each run queries the org.
237
243
  ```
238
244
 
239
245
  Example output:
@@ -291,73 +297,28 @@ FLAGS
291
297
  --permission-sets=<names> Comma-separated list to export (default: all assignable).
292
298
  ```
293
299
 
294
- ## CI/CD
300
+ ## Inspiration & equivalents
295
301
 
296
- A typical ladder: lint on every PR, plan against a sandbox, apply on merge:
302
+ This plugin's command surface borrows ideas from tools you already know:
297
303
 
298
- ```yaml
299
- # .github/workflows/ps-gitops.yml
300
- name: ps-gitops
301
- on:
302
- pull_request:
303
- push:
304
- branches: [main]
305
-
306
- jobs:
307
- check:
308
- runs-on: ubuntu-latest
309
- steps:
310
- - uses: actions/checkout@v4
311
- - run: npm install -g @salesforce/cli
312
- - run: sf plugins install sf-plugin-permission-sets
313
- - run: sf ps check --file "permissions/*.yml" --strict
314
-
315
- plan:
316
- if: github.event_name == 'pull_request'
317
- needs: check
318
- runs-on: ubuntu-latest
319
- steps:
320
- - uses: actions/checkout@v4
321
- - run: npm install -g @salesforce/cli
322
- - run: sf plugins install sf-plugin-permission-sets
323
- # Auth via Sfdx auth URL stored in a secrets manager, never hardcode credentials
324
- - run: echo "$SF_AUTH_URL" | sf org login sfdx-url --sfdx-url-stdin --alias target
325
- env:
326
- SF_AUTH_URL: ${{ secrets.SF_AUTH_URL }}
327
- - run: sf ps plan -o target --file "permissions/*.yml" --mode sync --fail-on-drift
328
-
329
- apply:
330
- if: github.ref == 'refs/heads/main' && github.event_name == 'push'
331
- needs: check
332
- runs-on: ubuntu-latest
333
- steps:
334
- - uses: actions/checkout@v4
335
- - run: npm install -g @salesforce/cli
336
- - run: sf plugins install sf-plugin-permission-sets
337
- - run: echo "$SF_AUTH_URL" | sf org login sfdx-url --sfdx-url-stdin --alias target
338
- env:
339
- SF_AUTH_URL: ${{ secrets.SF_AUTH_URL }}
340
- - run: sf ps apply -o target --file "permissions/*.yml" --mode sync --no-prompt --max-deletes 25
341
- ```
304
+ - [Terraform](https://developer.hashicorp.com/terraform/docs)
305
+ - [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/)
306
+ - [AWS SAM](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/)
307
+ - [Salesforce CLI](https://developer.salesforce.com/docs/atlas.en-us.sfdx_cli_reference.meta/sfdx_cli_reference/cli_reference.htm)
342
308
 
343
- > **Credentials:** the plugin never reads or stores secrets itself. It uses orgs you've already authenticated with `sf`. In CI, inject auth from your platform's secrets store (as above), not from committed files.
309
+ ## Versioning
344
310
 
345
- ## Inspiration & equivalents
311
+ Releases follow [semantic versioning](https://semver.org). Snapshots are automatic, real releases are a manual decision.
346
312
 
347
- The command surface borrows deliberately from tools you already know:
313
+ **Automatic, no action needed:**
348
314
 
349
- | This plugin | Terraform | CloudFormation / SAM | sf core |
350
- | -------------------- | ---------------------- | ------------------------------- | -------------------------- |
351
- | `ps check` | `terraform validate` | `sam validate --lint` | n/a |
352
- | `ps validate` | `terraform plan` (refresh) | `cfn validate-template` | `project deploy validate` |
353
- | `ps plan` | `terraform plan` | `cfn create-change-set` | `project deploy preview` |
354
- | `ps apply` | `terraform apply` | `cfn execute-change-set` / `sam deploy` | `project deploy start` |
355
- | `ps export` | `terraform import` | n/a | n/a |
356
- | `--fail-on-drift` | drift in plan exit code | `cfn detect-stack-drift` | n/a |
315
+ - Every push to `main` publishes a snapshot `0.0.0-dev.<run>` to the `dev` dist-tag.
316
+ - Creating a release triggers CI to build, stamp the version from the tag, publish it with provenance, and smoke-test the result.
357
317
 
358
- ## Versioning
318
+ **Manual, you decide and trigger:**
359
319
 
360
- Releases follow [semantic versioning](https://semver.org). You choose the bump by the git tag you create, and CI stamps and routes it to the right npm dist-tag.
320
+ - Choosing the version bump (patch, minor, or major).
321
+ - Creating the GitHub Release, which is what triggers the publish above.
361
322
 
362
323
  | Bump | When | Example tag |
363
324
  | --- | --- | --- |
@@ -365,19 +326,19 @@ Releases follow [semantic versioning](https://semver.org). You choose the bump b
365
326
  | minor | new backward-compatible feature | `v0.2.0` |
366
327
  | major | breaking change to a command, flag, or the YAML schema | `v1.0.0` |
367
328
 
368
- | dist-tag | Source | Install |
369
- | --- | --- | --- |
370
- | `latest` | a GitHub Release with a normal tag like `v1.2.0` | `sf plugins install sf-plugin-permission-sets` |
371
- | `next` | a Release whose tag has a hyphen, like `v1.3.0-beta.1` | `sf plugins install sf-plugin-permission-sets@next` |
372
- | `dev` | every push to `main`, published as `0.0.0-dev.<run>` | `sf plugins install sf-plugin-permission-sets@dev` |
373
-
374
329
  Cut a release with a tag off `main`:
375
330
 
376
331
  ```bash
377
332
  gh release create v0.2.0 --target main --title v0.2.0 --notes "Add ps export"
378
333
  ```
379
334
 
380
- The `next` tag is selected whenever the version contains a hyphen, not by GitHub's prerelease checkbox. The `dev` snapshots publish automatically on every push to `main`, so they need no command.
335
+ | dist-tag | Published by | Install |
336
+ | --- | --- | --- |
337
+ | `latest` | manual release with a normal tag like `v1.2.0` | `sf plugins install sf-plugin-permission-sets` |
338
+ | `next` | manual release with a hyphenated tag like `v1.3.0-beta.1` | `sf plugins install sf-plugin-permission-sets@next` |
339
+ | `dev` | automatic on every push to `main` | `sf plugins install sf-plugin-permission-sets@dev` |
340
+
341
+ The `next` tag is selected whenever the version contains a hyphen, not by GitHub's prerelease checkbox.
381
342
 
382
343
  ## License
383
344
 
@@ -0,0 +1,18 @@
1
+ import { SfCommand } from '@salesforce/sf-plugins-core';
2
+ import { Finding } from '../../core/model.js';
3
+ export type PsCheckResult = {
4
+ files: number;
5
+ users: number;
6
+ assignments: number;
7
+ findings: Finding[];
8
+ };
9
+ export default class Check extends SfCommand<PsCheckResult> {
10
+ static readonly summary: string;
11
+ static readonly description: string;
12
+ static readonly examples: string[];
13
+ static readonly flags: {
14
+ file: import("@oclif/core/interfaces").OptionFlag<string[], import("@oclif/core/interfaces").CustomOptions>;
15
+ strict: import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
+ };
17
+ run(): Promise<PsCheckResult>;
18
+ }
@@ -0,0 +1,45 @@
1
+ import { SfCommand, Flags } from '@salesforce/sf-plugins-core';
2
+ import { Messages } from '@salesforce/core';
3
+ import { CheckService } from '../../services/check.js';
4
+ import { formatFindings } from '../../core/report.js';
5
+ Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
6
+ const messages = Messages.loadMessages('sf-plugin-permission-sets', 'ps.check');
7
+ export default class Check extends SfCommand {
8
+ static summary = messages.getMessage('summary');
9
+ static description = messages.getMessage('description');
10
+ static examples = messages.getMessages('examples');
11
+ static flags = {
12
+ file: Flags.string({
13
+ char: 'f',
14
+ summary: messages.getMessage('flags.file.summary'),
15
+ required: true,
16
+ multiple: true,
17
+ }),
18
+ strict: Flags.boolean({
19
+ summary: messages.getMessage('flags.strict.summary'),
20
+ }),
21
+ };
22
+ async run() {
23
+ const { flags } = await this.parse(Check);
24
+ const result = await new CheckService().run({ files: flags.file, strict: flags.strict });
25
+ for (const line of formatFindings(result.findings)) {
26
+ this.log(line);
27
+ }
28
+ const users = new Set(result.assignments.map((a) => a.assignee)).size;
29
+ this.log('');
30
+ this.log(messages.getMessage('summary.counts', [String(result.errors), String(result.warnings)]));
31
+ if (result.failed) {
32
+ process.exitCode = 1;
33
+ if (!this.jsonEnabled()) {
34
+ this.error(messages.getMessage('error.failed'), { exit: 1 });
35
+ }
36
+ }
37
+ return {
38
+ files: result.files.length,
39
+ users,
40
+ assignments: result.assignments.length,
41
+ findings: result.findings,
42
+ };
43
+ }
44
+ }
45
+ //# sourceMappingURL=check.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"check.js","sourceRoot":"","sources":["../../../src/commands/ps/check.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM,6BAA6B,CAAC;AAC/D,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC5C,OAAO,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAC;AACvD,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAGtD,QAAQ,CAAC,kCAAkC,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAC7D,MAAM,QAAQ,GAAG,QAAQ,CAAC,YAAY,CAAC,2BAA2B,EAAE,UAAU,CAAC,CAAC;AAShF,MAAM,CAAC,OAAO,OAAO,KAAM,SAAQ,SAAwB;IAClD,MAAM,CAAU,OAAO,GAAG,QAAQ,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC;IACzD,MAAM,CAAU,WAAW,GAAG,QAAQ,CAAC,UAAU,CAAC,aAAa,CAAC,CAAC;IACjE,MAAM,CAAU,QAAQ,GAAG,QAAQ,CAAC,WAAW,CAAC,UAAU,CAAC,CAAC;IAE5D,MAAM,CAAU,KAAK,GAAG;QAC7B,IAAI,EAAE,KAAK,CAAC,MAAM,CAAC;YACjB,IAAI,EAAE,GAAG;YACT,OAAO,EAAE,QAAQ,CAAC,UAAU,CAAC,oBAAoB,CAAC;YAClD,QAAQ,EAAE,IAAI;YACd,QAAQ,EAAE,IAAI;SACf,CAAC;QACF,MAAM,EAAE,KAAK,CAAC,OAAO,CAAC;YACpB,OAAO,EAAE,QAAQ,CAAC,UAAU,CAAC,sBAAsB,CAAC;SACrD,CAAC;KACH,CAAC;IAEK,KAAK,CAAC,GAAG;QACd,MAAM,EAAE,KAAK,EAAE,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;QAC1C,MAAM,MAAM,GAAG,MAAM,IAAI,YAAY,EAAE,CAAC,GAAG,CAAC,EAAE,KAAK,EAAE,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC;QAEzF,KAAK,MAAM,IAAI,IAAI,cAAc,CAAC,MAAM,CAAC,QAAQ,CAAC,EAAE,CAAC;YACnD,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QACjB,CAAC;QAED,MAAM,KAAK,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC;QACtE,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QACb,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,UAAU,CAAC,gBAAgB,EAAE,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC;QAElG,IAAI,MAAM,CAAC,MAAM,EAAE,CAAC;YAClB,OAAO,CAAC,QAAQ,GAAG,CAAC,CAAC;YACrB,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,EAAE,CAAC;gBACxB,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,UAAU,CAAC,cAAc,CAAC,EAAE,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC,CAAC;YAC/D,CAAC;QACH,CAAC;QAED,OAAO;YACL,KAAK,EAAE,MAAM,CAAC,KAAK,CAAC,MAAM;YAC1B,KAAK;YACL,WAAW,EAAE,MAAM,CAAC,WAAW,CAAC,MAAM;YACtC,QAAQ,EAAE,MAAM,CAAC,QAAQ;SAC1B,CAAC;IACJ,CAAC"}
@@ -0,0 +1,8 @@
1
+ import { DesiredAssignment, Finding, LoadResult } from './model.js';
2
+ /** Process one file's text through parse, validate, and normalize. Pure, no disk. */
3
+ export declare function checkContent(text: string, file: string): {
4
+ assignments: DesiredAssignment[];
5
+ findings: Finding[];
6
+ };
7
+ /** Expand the globs, read every matched file, and merge into one model by union. */
8
+ export declare function loadFiles(patterns: string[]): Promise<LoadResult>;
@@ -0,0 +1,52 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { globby } from 'globby';
3
+ import { parseFile } from './parse.js';
4
+ import { validateFile } from './schema.js';
5
+ import { normalize } from './normalize.js';
6
+ /** Process one file's text through parse, validate, and normalize. Pure, no disk. */
7
+ export function checkContent(text, file) {
8
+ const parsed = parseFile(text, file);
9
+ if (!parsed.data) {
10
+ return { assignments: [], findings: parsed.findings };
11
+ }
12
+ const validated = validateFile(parsed.data, file);
13
+ if (!validated.data) {
14
+ return { assignments: [], findings: [...parsed.findings, ...validated.findings] };
15
+ }
16
+ const normalized = normalize(validated.data, file);
17
+ return {
18
+ assignments: normalized.assignments,
19
+ findings: [...parsed.findings, ...validated.findings, ...normalized.findings],
20
+ };
21
+ }
22
+ /** Expand the globs, read every matched file, and merge into one model by union. */
23
+ export async function loadFiles(patterns) {
24
+ const files = await globby(patterns);
25
+ if (files.length === 0) {
26
+ return {
27
+ files,
28
+ assignments: [],
29
+ findings: [{ level: 'error', code: 'NO_FILES', message: `no files matched: ${patterns.join(', ')}` }],
30
+ };
31
+ }
32
+ const findings = [];
33
+ const collected = [];
34
+ for (const file of files) {
35
+ // eslint-disable-next-line no-await-in-loop
36
+ const text = await readFile(file, 'utf8');
37
+ const res = checkContent(text, file);
38
+ findings.push(...res.findings);
39
+ collected.push(...res.assignments);
40
+ }
41
+ const seen = new Set();
42
+ const assignments = [];
43
+ for (const a of collected) {
44
+ const dedupeKey = `${a.assignee} ${a.kind} ${a.target}`;
45
+ if (seen.has(dedupeKey))
46
+ continue;
47
+ seen.add(dedupeKey);
48
+ assignments.push(a);
49
+ }
50
+ return { files, assignments, findings };
51
+ }
52
+ //# sourceMappingURL=load.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"load.js","sourceRoot":"","sources":["../../src/core/load.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC5C,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AACvC,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAG3C,qFAAqF;AACrF,MAAM,UAAU,YAAY,CAAC,IAAY,EAAE,IAAY;IACrD,MAAM,MAAM,GAAG,SAAS,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;IACrC,IAAI,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC;QACjB,OAAO,EAAE,WAAW,EAAE,EAAE,EAAE,QAAQ,EAAE,MAAM,CAAC,QAAQ,EAAE,CAAC;IACxD,CAAC;IAED,MAAM,SAAS,GAAG,YAAY,CAAC,MAAM,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;IAClD,IAAI,CAAC,SAAS,CAAC,IAAI,EAAE,CAAC;QACpB,OAAO,EAAE,WAAW,EAAE,EAAE,EAAE,QAAQ,EAAE,CAAC,GAAG,MAAM,CAAC,QAAQ,EAAE,GAAG,SAAS,CAAC,QAAQ,CAAC,EAAE,CAAC;IACpF,CAAC;IAED,MAAM,UAAU,GAAG,SAAS,CAAC,SAAS,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;IACnD,OAAO;QACL,WAAW,EAAE,UAAU,CAAC,WAAW;QACnC,QAAQ,EAAE,CAAC,GAAG,MAAM,CAAC,QAAQ,EAAE,GAAG,SAAS,CAAC,QAAQ,EAAE,GAAG,UAAU,CAAC,QAAQ,CAAC;KAC9E,CAAC;AACJ,CAAC;AAED,oFAAoF;AACpF,MAAM,CAAC,KAAK,UAAU,SAAS,CAAC,QAAkB;IAChD,MAAM,KAAK,GAAG,MAAM,MAAM,CAAC,QAAQ,CAAC,CAAC;IACrC,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACvB,OAAO;YACL,KAAK;YACL,WAAW,EAAE,EAAE;YACf,QAAQ,EAAE,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,UAAU,EAAE,OAAO,EAAE,qBAAqB,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC;SACtG,CAAC;IACJ,CAAC;IAED,MAAM,QAAQ,GAAc,EAAE,CAAC;IAC/B,MAAM,SAAS,GAAwB,EAAE,CAAC;IAC1C,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,4CAA4C;QAC5C,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;QAC1C,MAAM,GAAG,GAAG,YAAY,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;QACrC,QAAQ,CAAC,IAAI,CAAC,GAAG,GAAG,CAAC,QAAQ,CAAC,CAAC;QAC/B,SAAS,CAAC,IAAI,CAAC,GAAG,GAAG,CAAC,WAAW,CAAC,CAAC;IACrC,CAAC;IAED,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAC;IAC/B,MAAM,WAAW,GAAwB,EAAE,CAAC;IAC5C,KAAK,MAAM,CAAC,IAAI,SAAS,EAAE,CAAC;QAC1B,MAAM,SAAS,GAAG,GAAG,CAAC,CAAC,QAAQ,IAAI,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,MAAM,EAAE,CAAC;QACxD,IAAI,IAAI,CAAC,GAAG,CAAC,SAAS,CAAC;YAAE,SAAS;QAClC,IAAI,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;QACpB,WAAW,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IACtB,CAAC;IAED,OAAO,EAAE,KAAK,EAAE,WAAW,EAAE,QAAQ,EAAE,CAAC;AAC1C,CAAC"}
@@ -0,0 +1,19 @@
1
+ export type Kind = 'permissionSet' | 'permissionSetGroup' | 'permissionSetLicense';
2
+ export type DesiredAssignment = {
3
+ assignee: string;
4
+ kind: Kind;
5
+ target: string;
6
+ };
7
+ export type FindingLevel = 'error' | 'warning';
8
+ export type Finding = {
9
+ level: FindingLevel;
10
+ code: string;
11
+ message: string;
12
+ file?: string;
13
+ line?: number;
14
+ };
15
+ export type LoadResult = {
16
+ files: string[];
17
+ assignments: DesiredAssignment[];
18
+ findings: Finding[];
19
+ };
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=model.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"model.js","sourceRoot":"","sources":["../../src/core/model.ts"],"names":[],"mappings":""}
@@ -0,0 +1,10 @@
1
+ import { FileShape } from './schema.js';
2
+ import { DesiredAssignment, Finding } from './model.js';
3
+ /**
4
+ * Turn a validated file into canonical (assignee, kind, target) tuples, and
5
+ * emit the structural findings: duplicate targets, empty lists, empty users.
6
+ */
7
+ export declare function normalize(data: FileShape, file: string): {
8
+ assignments: DesiredAssignment[];
9
+ findings: Finding[];
10
+ };
@@ -0,0 +1,45 @@
1
+ const KIND_KEYS = [
2
+ ['permissionSet', 'permissionSets'],
3
+ ['permissionSetGroup', 'permissionSetGroups'],
4
+ ['permissionSetLicense', 'permissionSetLicenses'],
5
+ ];
6
+ /**
7
+ * Turn a validated file into canonical (assignee, kind, target) tuples, and
8
+ * emit the structural findings: duplicate targets, empty lists, empty users.
9
+ */
10
+ export function normalize(data, file) {
11
+ const assignments = [];
12
+ const findings = [];
13
+ for (const [username, entry] of Object.entries(data.users)) {
14
+ let scopeCount = 0;
15
+ for (const [kind, key] of KIND_KEYS) {
16
+ const list = entry[key];
17
+ if (list === undefined)
18
+ continue;
19
+ if (list.length === 0) {
20
+ findings.push({ level: 'warning', code: 'EMPTY_LIST', message: `${username}: ${key} is empty`, file });
21
+ continue;
22
+ }
23
+ scopeCount += 1;
24
+ const seen = new Set();
25
+ for (const target of list) {
26
+ if (seen.has(target)) {
27
+ findings.push({
28
+ level: 'warning',
29
+ code: 'DUP_TARGET',
30
+ message: `${username}: ${target} is listed twice under ${key}`,
31
+ file,
32
+ });
33
+ continue;
34
+ }
35
+ seen.add(target);
36
+ assignments.push({ assignee: username, kind, target });
37
+ }
38
+ }
39
+ if (scopeCount === 0) {
40
+ findings.push({ level: 'warning', code: 'EMPTY_USER', message: `${username}: no scopes declared`, file });
41
+ }
42
+ }
43
+ return { assignments, findings };
44
+ }
45
+ //# sourceMappingURL=normalize.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"normalize.js","sourceRoot":"","sources":["../../src/core/normalize.ts"],"names":[],"mappings":"AAKA,MAAM,SAAS,GAA4B;IACzC,CAAC,eAAe,EAAE,gBAAgB,CAAC;IACnC,CAAC,oBAAoB,EAAE,qBAAqB,CAAC;IAC7C,CAAC,sBAAsB,EAAE,uBAAuB,CAAC;CAClD,CAAC;AAEF;;;GAGG;AACH,MAAM,UAAU,SAAS,CAAC,IAAe,EAAE,IAAY;IACrD,MAAM,WAAW,GAAwB,EAAE,CAAC;IAC5C,MAAM,QAAQ,GAAc,EAAE,CAAC;IAE/B,KAAK,MAAM,CAAC,QAAQ,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;QAC3D,IAAI,UAAU,GAAG,CAAC,CAAC;QAEnB,KAAK,MAAM,CAAC,IAAI,EAAE,GAAG,CAAC,IAAI,SAAS,EAAE,CAAC;YACpC,MAAM,IAAI,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC;YACxB,IAAI,IAAI,KAAK,SAAS;gBAAE,SAAS;YACjC,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;gBACtB,QAAQ,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,YAAY,EAAE,OAAO,EAAE,GAAG,QAAQ,KAAK,GAAG,WAAW,EAAE,IAAI,EAAE,CAAC,CAAC;gBACvG,SAAS;YACX,CAAC;YAED,UAAU,IAAI,CAAC,CAAC;YAChB,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAC;YAC/B,KAAK,MAAM,MAAM,IAAI,IAAI,EAAE,CAAC;gBAC1B,IAAI,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC;oBACrB,QAAQ,CAAC,IAAI,CAAC;wBACZ,KAAK,EAAE,SAAS;wBAChB,IAAI,EAAE,YAAY;wBAClB,OAAO,EAAE,GAAG,QAAQ,KAAK,MAAM,0BAA0B,GAAG,EAAE;wBAC9D,IAAI;qBACL,CAAC,CAAC;oBACH,SAAS;gBACX,CAAC;gBACD,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;gBACjB,WAAW,CAAC,IAAI,CAAC,EAAE,QAAQ,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,CAAC;YACzD,CAAC;QACH,CAAC;QAED,IAAI,UAAU,KAAK,CAAC,EAAE,CAAC;YACrB,QAAQ,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,YAAY,EAAE,OAAO,EAAE,GAAG,QAAQ,sBAAsB,EAAE,IAAI,EAAE,CAAC,CAAC;QAC5G,CAAC;IACH,CAAC;IAED,OAAO,EAAE,WAAW,EAAE,QAAQ,EAAE,CAAC;AACnC,CAAC"}
@@ -0,0 +1,9 @@
1
+ import { Finding } from './model.js';
2
+ /**
3
+ * Read one file's text into a plain object. Reports invalid YAML and duplicate
4
+ * keys (uniqueKeys), and treats an empty document as a warning.
5
+ */
6
+ export declare function parseFile(text: string, file: string): {
7
+ data?: unknown;
8
+ findings: Finding[];
9
+ };
@@ -0,0 +1,25 @@
1
+ import { parseDocument } from 'yaml';
2
+ /**
3
+ * Read one file's text into a plain object. Reports invalid YAML and duplicate
4
+ * keys (uniqueKeys), and treats an empty document as a warning.
5
+ */
6
+ export function parseFile(text, file) {
7
+ const doc = parseDocument(text, { uniqueKeys: true });
8
+ if (doc.errors.length > 0) {
9
+ return {
10
+ findings: doc.errors.map((err) => ({
11
+ level: 'error',
12
+ code: 'YAML',
13
+ message: err.message,
14
+ file,
15
+ line: err.linePos?.[0]?.line,
16
+ })),
17
+ };
18
+ }
19
+ const data = doc.toJS();
20
+ if (data === null || data === undefined) {
21
+ return { findings: [{ level: 'warning', code: 'EMPTY_FILE', message: 'file is empty', file }] };
22
+ }
23
+ return { data, findings: [] };
24
+ }
25
+ //# sourceMappingURL=parse.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"parse.js","sourceRoot":"","sources":["../../src/core/parse.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,MAAM,CAAC;AAGrC;;;GAGG;AACH,MAAM,UAAU,SAAS,CAAC,IAAY,EAAE,IAAY;IAClD,MAAM,GAAG,GAAG,aAAa,CAAC,IAAI,EAAE,EAAE,UAAU,EAAE,IAAI,EAAE,CAAC,CAAC;IAEtD,IAAI,GAAG,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAC1B,OAAO;YACL,QAAQ,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;gBACjC,KAAK,EAAE,OAAO;gBACd,IAAI,EAAE,MAAM;gBACZ,OAAO,EAAE,GAAG,CAAC,OAAO;gBACpB,IAAI;gBACJ,IAAI,EAAE,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI;aAC7B,CAAC,CAAC;SACJ,CAAC;IACJ,CAAC;IAED,MAAM,IAAI,GAAG,GAAG,CAAC,IAAI,EAAa,CAAC;IACnC,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;QACxC,OAAO,EAAE,QAAQ,EAAE,CAAC,EAAE,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,YAAY,EAAE,OAAO,EAAE,eAAe,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC;IAClG,CAAC;IAED,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,EAAE,EAAE,CAAC;AAChC,CAAC"}
@@ -0,0 +1,8 @@
1
+ import { Finding } from './model.js';
2
+ /** Render findings as human-readable lines. Shared by check, validate, and plan. */
3
+ export declare function formatFindings(findings: Finding[]): string[];
4
+ /** Count findings by level. */
5
+ export declare function countFindings(findings: Finding[]): {
6
+ errors: number;
7
+ warnings: number;
8
+ };
@@ -0,0 +1,15 @@
1
+ /** Render findings as human-readable lines. Shared by check, validate, and plan. */
2
+ export function formatFindings(findings) {
3
+ return findings.map((finding) => {
4
+ const where = finding.file ? `${finding.file}${finding.line ? `:${finding.line}` : ''} ` : '';
5
+ return `${finding.level}: ${where}${finding.message}`;
6
+ });
7
+ }
8
+ /** Count findings by level. */
9
+ export function countFindings(findings) {
10
+ return {
11
+ errors: findings.filter((f) => f.level === 'error').length,
12
+ warnings: findings.filter((f) => f.level === 'warning').length,
13
+ };
14
+ }
15
+ //# sourceMappingURL=report.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"report.js","sourceRoot":"","sources":["../../src/core/report.ts"],"names":[],"mappings":"AAEA,oFAAoF;AACpF,MAAM,UAAU,cAAc,CAAC,QAAmB;IAChD,OAAO,QAAQ,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE;QAC9B,MAAM,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;QAC9F,OAAO,GAAG,OAAO,CAAC,KAAK,KAAK,KAAK,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC;IACxD,CAAC,CAAC,CAAC;AACL,CAAC;AAED,+BAA+B;AAC/B,MAAM,UAAU,aAAa,CAAC,QAAmB;IAC/C,OAAO;QACL,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,KAAK,OAAO,CAAC,CAAC,MAAM;QAC1D,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,KAAK,SAAS,CAAC,CAAC,MAAM;KAC/D,CAAC;AACJ,CAAC"}
@@ -0,0 +1,20 @@
1
+ import { z } from 'zod';
2
+ import { Finding } from './model.js';
3
+ export declare const userEntrySchema: z.ZodObject<{
4
+ permissionSets: z.ZodOptional<z.ZodArray<z.ZodString>>;
5
+ permissionSetGroups: z.ZodOptional<z.ZodArray<z.ZodString>>;
6
+ permissionSetLicenses: z.ZodOptional<z.ZodArray<z.ZodString>>;
7
+ }, z.core.$strict>;
8
+ export declare const fileSchema: z.ZodObject<{
9
+ users: z.ZodRecord<z.ZodString, z.ZodObject<{
10
+ permissionSets: z.ZodOptional<z.ZodArray<z.ZodString>>;
11
+ permissionSetGroups: z.ZodOptional<z.ZodArray<z.ZodString>>;
12
+ permissionSetLicenses: z.ZodOptional<z.ZodArray<z.ZodString>>;
13
+ }, z.core.$strict>>;
14
+ }, z.core.$strict>;
15
+ export type FileShape = z.infer<typeof fileSchema>;
16
+ /** Validate a parsed object against the file contract, turning issues into findings. */
17
+ export declare function validateFile(data: unknown, file: string): {
18
+ data?: FileShape;
19
+ findings: Finding[];
20
+ };
@@ -0,0 +1,26 @@
1
+ import { z } from 'zod';
2
+ const scopeList = z.array(z.string().min(1)).optional();
3
+ export const userEntrySchema = z.strictObject({
4
+ permissionSets: scopeList,
5
+ permissionSetGroups: scopeList,
6
+ permissionSetLicenses: scopeList,
7
+ });
8
+ export const fileSchema = z.strictObject({
9
+ users: z.record(z.string().min(1), userEntrySchema),
10
+ });
11
+ /** Validate a parsed object against the file contract, turning issues into findings. */
12
+ export function validateFile(data, file) {
13
+ const parsed = fileSchema.safeParse(data);
14
+ if (parsed.success) {
15
+ return { data: parsed.data, findings: [] };
16
+ }
17
+ return {
18
+ findings: parsed.error.issues.map((issue) => ({
19
+ level: 'error',
20
+ code: 'SCHEMA',
21
+ message: `${issue.path.join('.') || '(root)'}: ${issue.message}`,
22
+ file,
23
+ })),
24
+ };
25
+ }
26
+ //# sourceMappingURL=schema.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema.js","sourceRoot":"","sources":["../../src/core/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,MAAM,SAAS,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC;AAExD,MAAM,CAAC,MAAM,eAAe,GAAG,CAAC,CAAC,YAAY,CAAC;IAC5C,cAAc,EAAE,SAAS;IACzB,mBAAmB,EAAE,SAAS;IAC9B,qBAAqB,EAAE,SAAS;CACjC,CAAC,CAAC;AAEH,MAAM,CAAC,MAAM,UAAU,GAAG,CAAC,CAAC,YAAY,CAAC;IACvC,KAAK,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,eAAe,CAAC;CACpD,CAAC,CAAC;AAIH,wFAAwF;AACxF,MAAM,UAAU,YAAY,CAAC,IAAa,EAAE,IAAY;IACtD,MAAM,MAAM,GAAG,UAAU,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;IAC1C,IAAI,MAAM,CAAC,OAAO,EAAE,CAAC;QACnB,OAAO,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,EAAE,QAAQ,EAAE,EAAE,EAAE,CAAC;IAC7C,CAAC;IACD,OAAO;QACL,QAAQ,EAAE,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;YAC5C,KAAK,EAAE,OAAO;YACd,IAAI,EAAE,QAAQ;YACd,OAAO,EAAE,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,QAAQ,KAAK,KAAK,CAAC,OAAO,EAAE;YAChE,IAAI;SACL,CAAC,CAAC;KACJ,CAAC;AACJ,CAAC"}
@@ -0,0 +1,17 @@
1
+ import { DesiredAssignment, Finding } from '../core/model.js';
2
+ export type CheckOptions = {
3
+ files: string[];
4
+ strict: boolean;
5
+ };
6
+ export type CheckResult = {
7
+ files: string[];
8
+ assignments: DesiredAssignment[];
9
+ findings: Finding[];
10
+ errors: number;
11
+ warnings: number;
12
+ failed: boolean;
13
+ };
14
+ /** Offline check: load the files, validate them, and summarize the findings. */
15
+ export declare class CheckService {
16
+ run(options: CheckOptions): Promise<CheckResult>;
17
+ }
@@ -0,0 +1,20 @@
1
+ import { loadFiles } from '../core/load.js';
2
+ import { countFindings } from '../core/report.js';
3
+ /** Offline check: load the files, validate them, and summarize the findings. */
4
+ export class CheckService {
5
+ // eslint-disable-next-line class-methods-use-this
6
+ async run(options) {
7
+ const loaded = await loadFiles(options.files);
8
+ const { errors, warnings } = countFindings(loaded.findings);
9
+ const failed = errors > 0 || (options.strict && warnings > 0);
10
+ return {
11
+ files: loaded.files,
12
+ assignments: loaded.assignments,
13
+ findings: loaded.findings,
14
+ errors,
15
+ warnings,
16
+ failed,
17
+ };
18
+ }
19
+ }
20
+ //# sourceMappingURL=check.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"check.js","sourceRoot":"","sources":["../../src/services/check.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAC5C,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAiBlD,gFAAgF;AAChF,MAAM,OAAO,YAAY;IACvB,kDAAkD;IAC3C,KAAK,CAAC,GAAG,CAAC,OAAqB;QACpC,MAAM,MAAM,GAAG,MAAM,SAAS,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;QAC9C,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,GAAG,aAAa,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;QAC5D,MAAM,MAAM,GAAG,MAAM,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,IAAI,QAAQ,GAAG,CAAC,CAAC,CAAC;QAE9D,OAAO;YACL,KAAK,EAAE,MAAM,CAAC,KAAK;YACnB,WAAW,EAAE,MAAM,CAAC,WAAW;YAC/B,QAAQ,EAAE,MAAM,CAAC,QAAQ;YACzB,MAAM;YACN,QAAQ;YACR,MAAM;SACP,CAAC;IACJ,CAAC;CACF"}
@@ -0,0 +1,33 @@
1
+ # summary
2
+
3
+ Statically check permission set assignment files, with no org connection.
4
+
5
+ # description
6
+
7
+ Parse and validate the YAML files offline. Reports invalid YAML, schema violations, duplicate keys, duplicate targets, and empty entries. Run it in a pre-commit hook or in CI before validate, plan, or apply.
8
+
9
+ # flags.file.summary
10
+
11
+ YAML file or glob to check. Repeatable.
12
+
13
+ # flags.strict.summary
14
+
15
+ Treat warnings as errors.
16
+
17
+ # summary.counts
18
+
19
+ %s errors, %s warnings.
20
+
21
+ # error.failed
22
+
23
+ Check found problems. See the output above.
24
+
25
+ # examples
26
+
27
+ - Check every file under permissions:
28
+
29
+ <%= config.bin %> <%= command.id %> --file "permissions/*.yml"
30
+
31
+ - Check specific files:
32
+
33
+ <%= config.bin %> <%= command.id %> --file permissions/sales.yml --file permissions/service.yml