Skip to content
This repository was archived by the owner on Aug 17, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file modified .husky/commit-msg
100644 → 100755
Empty file.
Empty file modified .husky/pre-commit
100644 → 100755
Empty file.
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0/).

## [0.3.3] - 2026-06-23

### Added

- Multiple personas and default configurations

## [0.3.2] - 2026-06-02

### Changed

- Internal maintenance and tooling updates

## [0.3.1] - 2026-06-02

### Changed
Expand Down
94 changes: 67 additions & 27 deletions README.md

Large diffs are not rendered by default.

50 changes: 49 additions & 1 deletion messages/jawn.user.provision.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Provision users from user and persona definition files.

# description

Provisions Salesforce users by merging persona defaults with user overrides, enforcing optional profile and role, activating and unfreezing users, and planning or applying assignment changes.
Provisions Salesforce users by merging multiple personas per user into an effective persona (unioning assignment lists, enforcing singular-value agreement), applying insert-only Username and Alias defaults, enforcing optional profile and role, activating and unfreezing users, and planning or applying assignment changes.

# flags.target-org.summary

Expand Down Expand Up @@ -90,6 +90,54 @@ Save operation returned no user id.

Cross-reference update candidates for this user: %s

# errorNoPersonas

Each user must include a non-empty personas array.

# errorLegacyPersonaKey

"persona" is no longer supported; use "personas": [ ... ].

# errorUnknownPersona

Unknown persona "%s".

# errorPersonaConflictProfile

Personas conflict on profile: %s.

# errorPersonaConflictRole

Personas conflict on role: %s.

# errorPersonaConflictUserAttribute

Personas conflict on userAttribute "%s".

# errorPersonaConflictMode

Personas conflict on %s.

# errorUserProfileConflict

Set either "profile" or "ProfileId" on a user, not both.

# errorUserRoleConflict

Set either "role" or "UserRoleId" on a user, not both.

# errorInvalidUserProfile

user "profile" must be a string (a profile name or Id). Got: %s

# errorInvalidUserRole

user "role" must be a string (a role name/DeveloperName or Id). Got: %s

# warningUserFailed

%s failed: %s

# info.summary

Processed %s users: %s created, %s updated, %s failed.
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@syntax-syllogism/jawn",
"description": "a sf cli plugin for admin and developer workflows",
"version": "0.3.1",
"version": "0.3.3",
"repository": {
"type": "git",
"url": "git+https://github.com/Syntax-Syllogism/jawn.git"
Expand All @@ -26,7 +26,7 @@
"eslint-plugin-sf-plugin": "^1.18.6",
"oclif": "^4.14.0",
"ts-node": "^10.9.2",
"typescript": "^5.4.5"
"typescript": "^5.9.3"
},
"engines": {
"node": ">=18.0.0"
Expand Down
97 changes: 67 additions & 30 deletions src/commands/jawn/user/provision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ import { readFile } from 'node:fs/promises';
import { Connection, Messages, SfError } from '@salesforce/core';
import { Flags, SfCommand } from '@salesforce/sf-plugins-core';
import {
buildDefaultAlias,
buildDefaultUsername,
buildFieldMap,
CanonicalizedUser,
deriveMyDomain,
isSalesforceId,
missingRequiredFieldsForInsert,
normalizeMode,
Expand Down Expand Up @@ -39,7 +42,8 @@ type UserPlan = {
planId: string;
order: number;
key: string;
persona: string;
personas: string[];
effectivePersona: PersonaDefinition;
matchedBy: string | null;
target: JsonRecord;
existing?: ExistingUser;
Expand All @@ -58,7 +62,7 @@ type OrderedUserResult = UserResult & { order: number; planId: string };
type UserResult = {
key: string;
id?: string;
persona: string;
personas: string[];
matchedBy: string | null;
status: 'created' | 'updated' | 'failed' | 'planned';
actions: string[];
Expand Down Expand Up @@ -191,10 +195,15 @@ const resolveByRoleRef = async (

const resolveReferences = async (
conn: Connection,
personas: Record<string, PersonaDefinition>
personas: Record<string, PersonaDefinition>,
users: CanonicalizedUser[]
): Promise<ResolvedRefs> => {
const warnings: string[] = [];
const refs = collectPersonaRefs(personas);
for (const user of users) {
if (user.profileRef) refs.profiles.add(user.profileRef);
if (user.roleRef) refs.roles.add(user.roleRef);
}
const [
profilesByRef,
rolesByRef,
Expand Down Expand Up @@ -280,19 +289,25 @@ const ensureWritableFields = (
}
};

const buildTarget = (
user: CanonicalizedUser,
persona: PersonaDefinition,
refs: ResolvedRefs,
errors: string[]
): JsonRecord => {
const buildTarget = (user: CanonicalizedUser, refs: ResolvedRefs, errors: string[]): JsonRecord => {
const persona = user.effectivePersona;
const target: JsonRecord = { ...user.fields, IsActive: true };
if (persona.profile) {
// Profile: user profileRef > user raw ProfileId (already in target) > persona profile
if (user.profileRef) {
const profileId = refs.profilesByRef.get(user.profileRef);
if (!profileId) errors.push(messages.getMessage('errorReferenceRequiredMissing', ['Profile', user.profileRef]));
else target.ProfileId = profileId;
} else if (!target.ProfileId && persona.profile) {
const profileId = refs.profilesByRef.get(persona.profile);
if (!profileId) errors.push(messages.getMessage('errorReferenceRequiredMissing', ['Profile', persona.profile]));
else target.ProfileId = profileId;
}
if (persona.role) {
// Role: user roleRef > user raw UserRoleId (already in target) > persona role
if (user.roleRef) {
const roleId = refs.rolesByRef.get(user.roleRef);
if (!roleId) errors.push(messages.getMessage('errorReferenceRequiredMissing', ['UserRole', user.roleRef]));
else target.UserRoleId = roleId;
} else if (!target.UserRoleId && persona.role) {
const roleId = refs.rolesByRef.get(persona.role);
if (!roleId) errors.push(messages.getMessage('errorReferenceRequiredMissing', ['UserRole', persona.role]));
else target.UserRoleId = roleId;
Expand Down Expand Up @@ -553,6 +568,17 @@ const executeBulkUserSaves = async (conn: Connection, plans: UserPlan[]): Promis
return outcomes;
};

const applyInsertDefaults = (target: JsonRecord, myDomain: string | undefined): void => {
if (!target.Username && myDomain) {
const u = buildDefaultUsername(target.Email, myDomain);
if (u) target.Username = u;
}
if (!target.Alias) {
const a = buildDefaultAlias(target.FirstName, target.LastName);
if (a) target.Alias = a;
}
};

export default class UserProvision extends SfCommand<ProvisionResult> {
public static readonly summary = messages.getMessage('summary');
public static readonly description = messages.getMessage('description');
Expand Down Expand Up @@ -596,6 +622,7 @@ export default class UserProvision extends SfCommand<ProvisionResult> {
validatePersonaModes(personas);
validateExternalIdFieldForFlag(flags['external-id'], fieldMap);
const users = validateAndCanonicalizeUsers(usersDoc.users, personas, fieldMap);
const myDomain = deriveMyDomain(conn.instanceUrl);
const defaultExternalIdField = flags['external-id']
? fieldMap.get(flags['external-id'].toLowerCase())?.name
: undefined;
Expand All @@ -607,7 +634,11 @@ export default class UserProvision extends SfCommand<ProvisionResult> {
const validUsers = userEntries.filter(({ user }) => !user.validationErrors || user.validationErrors.length === 0);

const [refs, existingResolution] = await Promise.all([
resolveReferences(conn, personas),
resolveReferences(
conn,
personas,
validUsers.map(({ user }) => user)
),
getExistingUsers(
conn,
validUsers.map(({ user }) => user),
Expand All @@ -620,9 +651,9 @@ export default class UserProvision extends SfCommand<ProvisionResult> {
}

const plans: UserPlan[] = validUsers.map(({ user, order }) => {
const persona = personas[user.persona];
const effectivePersona = user.effectivePersona;
const errors: string[] = [];
const target = buildTarget(user, persona, refs, errors);
const target = buildTarget(user, refs, errors);
const matchedBy = matchFieldFor(user) ?? null;
const matchValue = matchedBy ? target[matchedBy] : undefined;
const existing =
Expand All @@ -637,7 +668,9 @@ export default class UserProvision extends SfCommand<ProvisionResult> {
errors.push(messages.getMessage('errorDuplicateExternalIdMatch', [matchedBy, matchValue]));
}
if (!existing) {
const missing = missingRequiredFieldsForInsert(target, persona);
applyInsertDefaults(target, myDomain);
const profileIntended = Boolean(user.profileRef) || Boolean(effectivePersona.profile);
const missing = missingRequiredFieldsForInsert(target, profileIntended);
if (missing.length > 0) errors.push(messages.getMessage('errorMissingRequiredFields', [missing.join(', ')]));
}
ensureWritableFields(target, existing, fieldMap, errors);
Expand All @@ -646,10 +679,11 @@ export default class UserProvision extends SfCommand<ProvisionResult> {
];
if (!existing || existing.IsActive !== true) actions.push(flags['dry-run'] ? 'wouldActivate' : 'activated');
return {
planId: `${order}:${user.inputKey}:${user.persona}`,
planId: `${order}:${user.inputKey}:${user.personas.join('+')}`,
order,
key: user.inputKey,
persona: user.persona,
personas: user.personas,
effectivePersona,
matchedBy,
target,
existing,
Expand All @@ -659,10 +693,10 @@ export default class UserProvision extends SfCommand<ProvisionResult> {
});

const validationResults: OrderedUserResult[] = validationFailureUsers.map(({ user, order }) => ({
planId: `${order}:${user.inputKey}:${user.persona}:validation`,
planId: `${order}:${user.inputKey}:${user.personas.join('+')}:validation`,
order,
key: user.inputKey,
persona: user.persona,
personas: user.personas,
matchedBy: user.matchField ?? null,
status: 'failed',
actions: [],
Expand All @@ -675,14 +709,13 @@ export default class UserProvision extends SfCommand<ProvisionResult> {
planId: plan.planId,
order: plan.order,
key: plan.key,
persona: plan.persona,
personas: plan.personas,
matchedBy: plan.matchedBy,
status: 'failed',
actions: plan.actions,
errors: plan.errors,
};
}
const persona = personas[plan.persona];
if (flags['dry-run']) {
const dryRunId = plan.existing?.Id ?? DRY_RUN_CREATE_ID;
if (plan.existing) {
Expand All @@ -693,13 +726,13 @@ export default class UserProvision extends SfCommand<ProvisionResult> {
).records;
if (frozenRows.length > 0) plan.actions.push('wouldUnfreeze');
}
await applyAssignments(conn, dryRunId, persona, refs, true, plan.actions, plan.errors);
await applyAssignments(conn, dryRunId, plan.effectivePersona, refs, true, plan.actions, plan.errors);
return {
planId: plan.planId,
order: plan.order,
key: plan.key,
id: plan.existing?.Id,
persona: plan.persona,
personas: plan.personas,
matchedBy: plan.matchedBy,
status: 'planned',
actions: plan.actions,
Expand All @@ -715,14 +748,13 @@ export default class UserProvision extends SfCommand<ProvisionResult> {
planId: plan.planId,
order: plan.order,
key: plan.key,
persona: plan.persona,
personas: plan.personas,
matchedBy: plan.matchedBy,
status: 'failed',
actions: plan.actions,
errors: [messages.getMessage('errorMissingSaveId')],
};
}
const persona = personas[plan.persona];
const frozenRows = (
await conn.query<{ Id: string }>(`SELECT Id FROM UserLogin WHERE UserId = '${esc(id)}' AND IsFrozen = true`)
).records;
Expand All @@ -736,14 +768,14 @@ export default class UserProvision extends SfCommand<ProvisionResult> {
if (unfreezeErrors.length > 0) plan.errors.push(...unfreezeErrors);
else plan.actions.push('unfrozen');
}
await applyAssignments(conn, id, persona, refs, false, plan.actions, plan.errors);
await applyAssignments(conn, id, plan.effectivePersona, refs, false, plan.actions, plan.errors);
const status = plan.errors.length > 0 ? 'failed' : plan.existing ? 'updated' : 'created';
return {
planId: plan.planId,
order: plan.order,
key: plan.key,
id,
persona: plan.persona,
personas: plan.personas,
matchedBy: plan.matchedBy,
status,
actions: plan.actions,
Expand All @@ -757,7 +789,7 @@ export default class UserProvision extends SfCommand<ProvisionResult> {
planId: p.planId,
order: p.order,
key: p.key,
persona: p.persona,
personas: p.personas,
matchedBy: p.matchedBy,
status: 'failed',
actions: p.actions,
Expand All @@ -776,7 +808,7 @@ export default class UserProvision extends SfCommand<ProvisionResult> {
planId: o.plan.planId,
order: o.plan.order,
key: o.plan.key,
persona: o.plan.persona,
personas: o.plan.personas,
matchedBy: o.plan.matchedBy,
status: 'failed' as const,
actions: o.plan.actions,
Expand All @@ -795,7 +827,7 @@ export default class UserProvision extends SfCommand<ProvisionResult> {
.map((result) => ({
key: result.key,
id: result.id,
persona: result.persona,
personas: result.personas,
matchedBy: result.matchedBy ?? null,
status: result.status,
actions: result.actions,
Expand All @@ -804,6 +836,11 @@ export default class UserProvision extends SfCommand<ProvisionResult> {

const output: ProvisionResult = { summary: summarize(results, refs.warnings.length), users: results };
if (!this.jsonEnabled()) {
for (const user of results) {
if (user.errors.length > 0) {
this.warn(messages.getMessage('warningUserFailed', [user.key, user.errors.join('; ')]));
}
}
this.log(
messages.getMessage('info.summary', [
output.summary.total,
Expand Down
Loading