From 380f0320a5ab3b64b0993478eef41ee1afa9858b Mon Sep 17 00:00:00 2001 From: Matthew Rollinson Date: Thu, 9 Jul 2026 14:12:11 +0100 Subject: [PATCH 1/3] fix(rules): skip deletes by default and protect system roles/policies Partial hand-authored rulesets used to mark Administrator/Public as removed and delete them when push ran with an admin token. skipDeletes now defaults to true; pass --delete (or skipDeletes: false) to allow remote-only removals. Administrator, Public, admin_access policies, and known system names are never deleted regardless of the flag. --- src/cli/index.ts | 14 ++++++++++++-- src/rules/index.ts | 2 ++ src/rules/sync/index.ts | 1 + src/rules/sync/protect.ts | 31 +++++++++++++++++++++++++++++++ src/rules/sync/push.ts | 18 ++++++++++++++++-- src/rules/sync/types.ts | 6 +++++- test/rules/protect.test.ts | 33 +++++++++++++++++++++++++++++++++ 7 files changed, 100 insertions(+), 5 deletions(-) create mode 100644 src/rules/sync/protect.ts create mode 100644 test/rules/protect.test.ts diff --git a/src/cli/index.ts b/src/cli/index.ts index 7a456c4..d55d3f1 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -113,7 +113,9 @@ Options: --compact Output compact JSON (no pretty-print) --dry-run Show what would be changed without making changes (rules:push) --add-only Only add new items, don't modify or delete existing (rules:push) - --skip-deletes Skip deleting items that exist remotely but not locally (rules:push) + --skip-deletes Skip deleting remote-only items (rules:push; default) + --delete Allow deleting remote roles/policies/permissions missing from the local file + (Administrator / Public still never deleted) --prefix Prefix for custom collection type names (generate-types) --include Comma-separated collection names to include (generate-types). When set, only these collections (plus any they reference @@ -156,6 +158,9 @@ Examples: # Push only new items (safe mode) npx nuxt-directus-sdk rules:push rules.json --add-only + # Also delete remote items missing from the local file + npx nuxt-directus-sdk rules:push rules.json --delete + # Compare local file with remote npx nuxt-directus-sdk rules:diff rules.json @@ -411,7 +416,10 @@ async function main(): Promise { 'compact': { type: 'boolean', default: false }, 'dry-run': { type: 'boolean', default: false }, 'add-only': { type: 'boolean', default: false }, + // Deletes are off by default; --delete opts in. --skip-deletes is kept as + // an explicit no-op alias of the new default for backwards compatibility. 'skip-deletes': { type: 'boolean', default: false }, + 'delete': { type: 'boolean', default: false }, 'prefix': { type: 'string', default: '' }, 'include': { type: 'string' }, 'exclude': { type: 'string' }, @@ -467,7 +475,9 @@ async function main(): Promise { await commandPush(positionals[1], connection, { dryRun: values['dry-run']!, addOnly: values['add-only']!, - skipDeletes: values['skip-deletes']!, + // Default skips deletes; --delete opts in. --skip-deletes remains a no-op + // (same as default) for scripts written against the old flag. + skipDeletes: !values.delete, }) break } diff --git a/src/rules/index.ts b/src/rules/index.ts index 85019e5..15e0fc0 100644 --- a/src/rules/index.ts +++ b/src/rules/index.ts @@ -90,6 +90,8 @@ export { fetchRemoteRulesAsJson, formatDiff, formatPushResult, + isProtectedPolicy, + isProtectedRole, pullRules, pushRules, } from './sync' diff --git a/src/rules/sync/index.ts b/src/rules/sync/index.ts index 803b8ee..4172b54 100644 --- a/src/rules/sync/index.ts +++ b/src/rules/sync/index.ts @@ -6,6 +6,7 @@ export type { CompareOptions } from './diff' export { compareRulesPayloads, diffRemoteRules, diffRules, fetchRemoteRules, fetchRemoteRulesAsJson, pullRules } from './diff' export { formatDiff } from './format' +export { isProtectedPolicy, isProtectedRole } from './protect' export { formatPushResult, pushRules } from './push' export type { ChangeType, diff --git a/src/rules/sync/protect.ts b/src/rules/sync/protect.ts new file mode 100644 index 0000000..ee375db --- /dev/null +++ b/src/rules/sync/protect.ts @@ -0,0 +1,31 @@ +/** + * Entities that must never be deleted by rules sync, even when the local + * payload is a partial hand-authored set and skipDeletes is false. + */ + +import type { DirectusPolicyPayload, DirectusRolePayload } from '../types/directus-api' + +/** Role names that are Directus defaults / infrastructure. */ +const PROTECTED_ROLE_NAMES = new Set([ + 'Administrator', + 'Public', + '$t:public_label', +]) + +/** Policy names that are Directus defaults / infrastructure. */ +const PROTECTED_POLICY_NAMES = new Set([ + 'Administrator', + 'Public', + '$t:admin_policy', + '$t:public_policy', +]) + +export function isProtectedRole(role: Pick): boolean { + return PROTECTED_ROLE_NAMES.has(role.name) +} + +export function isProtectedPolicy(policy: Pick): boolean { + if (policy.admin_access === true) + return true + return PROTECTED_POLICY_NAMES.has(policy.name) +} diff --git a/src/rules/sync/push.ts b/src/rules/sync/push.ts index 6d1320f..68d6efb 100644 --- a/src/rules/sync/push.ts +++ b/src/rules/sync/push.ts @@ -21,6 +21,7 @@ import { } from '@directus/sdk' import { serializeToDirectusApi } from '../utils/serialize' import { compareRulesPayloads, fetchRemoteRules } from './diff' +import { isProtectedPolicy, isProtectedRole } from './protect' /** * Push local rules to a remote Directus instance @@ -57,7 +58,7 @@ export async function pushRules( localRules: RulesConfig, options: PushOptions = {}, ): Promise { - const { addOnly = false, skipDeletes = false, onProgress } = options + const { addOnly = false, skipDeletes = true, onProgress } = options const result: PushResult = { success: true, @@ -331,7 +332,8 @@ export async function pushRules( } } - // 6. Delete in reverse order (if not skipped) + // 6. Delete in reverse order (if not skipped). Built-in Administrator / + // Public entities are never deleted, even when skipDeletes is false. if (!skipDeletes && !addOnly) { // Delete permissions first const permsToDelete = diff.permissions.filter(p => p.type === 'removed') @@ -369,6 +371,12 @@ export async function pushRules( total: rolesToDelete.length, }) + if (change.remote && isProtectedRole(change.remote)) { + const message = `Skipped deleting protected role "${change.name}"` + result.roles.push({ type: 'skipped', name: change.name, id: change.remote.id, error: message }) + continue + } + try { await client.request(deleteRole(change.remote!.id!)) result.roles.push({ type: 'deleted', name: change.name, id: change.remote!.id }) @@ -394,6 +402,12 @@ export async function pushRules( total: policiesToDelete.length, }) + if (change.remote && isProtectedPolicy(change.remote)) { + const message = `Skipped deleting protected policy "${change.name}"` + result.policies.push({ type: 'skipped', name: change.name, id: change.remote.id, error: message }) + continue + } + try { await client.request(deletePolicy(change.remote!.id!)) result.policies.push({ type: 'deleted', name: change.name, id: change.remote!.id }) diff --git a/src/rules/sync/types.ts b/src/rules/sync/types.ts index da6eab0..738fb1a 100644 --- a/src/rules/sync/types.ts +++ b/src/rules/sync/types.ts @@ -78,7 +78,11 @@ export interface PushOptions { /** * If true, skip deleting items that exist remotely but not locally. - * @default false + * Defaults to true so partial hand-authored rulesets cannot wipe remote + * roles/policies by omission. Pass `false` (or CLI `--delete`) to enable + * destructive sync. Built-in Administrator/Public entities are never deleted + * regardless of this flag. + * @default true */ skipDeletes?: boolean diff --git a/test/rules/protect.test.ts b/test/rules/protect.test.ts new file mode 100644 index 0000000..c9ca337 --- /dev/null +++ b/test/rules/protect.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { isProtectedPolicy, isProtectedRole } from '../../src/rules/sync/protect' + +describe('isProtectedRole', () => { + it('protects Administrator and Public', () => { + expect(isProtectedRole({ name: 'Administrator' })).toBe(true) + expect(isProtectedRole({ name: 'Public' })).toBe(true) + expect(isProtectedRole({ name: '$t:public_label' })).toBe(true) + }) + + it('allows custom roles', () => { + expect(isProtectedRole({ name: 'Editor' })).toBe(false) + }) +}) + +describe('isProtectedPolicy', () => { + it('protects admin_access policies regardless of name', () => { + expect(isProtectedPolicy({ + name: 'Custom Admin', + admin_access: true, + })).toBe(true) + }) + + it('protects known system policy names', () => { + expect(isProtectedPolicy({ name: 'Administrator', admin_access: false })).toBe(true) + expect(isProtectedPolicy({ name: 'Public', admin_access: false })).toBe(true) + expect(isProtectedPolicy({ name: '$t:admin_policy', admin_access: false })).toBe(true) + }) + + it('allows custom app policies', () => { + expect(isProtectedPolicy({ name: 'Content', admin_access: false })).toBe(false) + }) +}) From 43900ed46ab02b3857d560738cec57a47d83ebc0 Mon Sep 17 00:00:00 2001 From: Matthew Rollinson Date: Thu, 9 Jul 2026 15:45:43 +0100 Subject: [PATCH 2/3] fix(rules): reject conflicting delete flags and surface protected skips - rules:push errors when --skip-deletes and --delete are both passed instead of silently letting --delete win - protected role/policy skips now use a dedicated reason field so they are not conflated with error skips, and formatPushResult lists them under a Skipped section so --delete users can see why nothing was removed --- src/cli/index.ts | 4 ++++ src/rules/sync/push.ts | 27 +++++++++++++++++++++++---- src/rules/sync/types.ts | 2 ++ test/rules/sync.test.ts | 27 +++++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index d55d3f1..776cd8b 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -467,6 +467,10 @@ async function main(): Promise { console.error('Usage: npx nuxt-directus-sdk rules:push [--dry-run]') process.exit(1) } + if (values['skip-deletes'] && values.delete) { + console.error('Error: --skip-deletes and --delete cannot be used together') + process.exit(1) + } const connection = getConnectionConfig( values['source-url'] ?? values.url, values['source-token'] ?? values.token, diff --git a/src/rules/sync/push.ts b/src/rules/sync/push.ts index 68d6efb..9ce5a02 100644 --- a/src/rules/sync/push.ts +++ b/src/rules/sync/push.ts @@ -372,8 +372,12 @@ export async function pushRules( }) if (change.remote && isProtectedRole(change.remote)) { - const message = `Skipped deleting protected role "${change.name}"` - result.roles.push({ type: 'skipped', name: change.name, id: change.remote.id, error: message }) + result.roles.push({ + type: 'skipped', + name: change.name, + id: change.remote.id, + reason: `Protected role "${change.name}" was not deleted`, + }) continue } @@ -403,8 +407,12 @@ export async function pushRules( }) if (change.remote && isProtectedPolicy(change.remote)) { - const message = `Skipped deleting protected policy "${change.name}"` - result.policies.push({ type: 'skipped', name: change.name, id: change.remote.id, error: message }) + result.policies.push({ + type: 'skipped', + name: change.name, + id: change.remote.id, + reason: `Protected policy "${change.name}" was not deleted`, + }) continue } @@ -449,6 +457,17 @@ export function formatPushResult(result: PushResult): string { lines.push(` Roles: +${summary.roles.created} ~${summary.roles.updated} -${summary.roles.deleted}${summary.roles.errors ? ` (${summary.roles.errors} errors)` : ''}`) lines.push(` Perms: +${summary.permissions.created} ~${summary.permissions.updated} -${summary.permissions.deleted}${summary.permissions.errors ? ` (${summary.permissions.errors} errors)` : ''}`) + const skipped = [...result.policies, ...result.roles, ...result.permissions] + .filter(item => item.type === 'skipped' && item.reason) + + if (skipped.length > 0) { + lines.push('') + lines.push('Skipped:') + for (const item of skipped) { + lines.push(` - ${item.reason}`) + } + } + if (result.errors.length > 0) { lines.push('') lines.push('Errors:') diff --git a/src/rules/sync/types.ts b/src/rules/sync/types.ts index 738fb1a..330de17 100644 --- a/src/rules/sync/types.ts +++ b/src/rules/sync/types.ts @@ -107,6 +107,8 @@ export interface PushOperationResult { name: string id?: string error?: string + /** Present when the item was skipped intentionally (e.g. protected system entity) */ + reason?: string } /** Result of pushing rules */ diff --git a/test/rules/sync.test.ts b/test/rules/sync.test.ts index 8319ad7..63d4b89 100644 --- a/test/rules/sync.test.ts +++ b/test/rules/sync.test.ts @@ -613,6 +613,33 @@ describe('sync: formatPushResult', () => { expect(output).toContain('1 errors') }) + it('lists intentionally skipped protected entities without marking them as errors', () => { + const result: PushResult = { + success: true, + roles: [ + { type: 'skipped', name: 'Administrator', id: 'role-1', reason: 'Protected role "Administrator" was not deleted' }, + ], + policies: [ + { type: 'skipped', name: 'Public', id: 'policy-1', reason: 'Protected policy "Public" was not deleted' }, + ], + permissions: [], + summary: { + roles: { created: 0, updated: 0, deleted: 0, errors: 0 }, + policies: { created: 0, updated: 0, deleted: 0, errors: 0 }, + permissions: { created: 0, updated: 0, deleted: 0, errors: 0 }, + }, + errors: [], + } + + const output = formatPushResult(result) + + expect(output).toContain('SUCCESS') + expect(output).toContain('Skipped:') + expect(output).toContain('Protected role "Administrator" was not deleted') + expect(output).toContain('Protected policy "Public" was not deleted') + expect(output).not.toContain('Errors:') + }) + it('formats empty push result (no changes)', () => { const result: PushResult = { success: true, From 67bfc3b68a619b6744b2b493424e258ef98ee792 Mon Sep 17 00:00:00 2001 From: Matthew Rollinson Date: Mon, 5 Oct 2026 17:57:37 +0100 Subject: [PATCH 3/3] docs(rules): document additive push default and --delete --- docs/cli/rules.md | 9 ++++++--- docs/guide/rules-sync.md | 12 ++++++++---- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/docs/cli/rules.md b/docs/cli/rules.md index 034300f..4921613 100644 --- a/docs/cli/rules.md +++ b/docs/cli/rules.md @@ -48,8 +48,8 @@ npx nuxt-directus-sdk rules:push rules.json # Only add new items, never modify or delete existing ones npx nuxt-directus-sdk rules:push rules.json --add-only -# Skip deletions (useful for additive migrations) -npx nuxt-directus-sdk rules:push rules.json --skip-deletes +# Also delete remote items that are missing from the local file +npx nuxt-directus-sdk rules:push rules.json --delete ``` **Flags:** @@ -58,7 +58,10 @@ npx nuxt-directus-sdk rules:push rules.json --skip-deletes | --- | --- | --- | | `--dry-run` | `false` | Report what would be changed, don't actually change anything | | `--add-only` | `false` | Only create new items, never modify or delete existing items | -| `--skip-deletes` | `false` | Don't delete items that exist remotely but not locally | +| `--delete` | `false` | Also delete roles, policies and permissions that exist remotely but not locally | +| `--skip-deletes` | | No-op kept for older scripts; skipping deletes is now the default | + +Pushes are additive by default, so a partial rules file never removes anything from the remote instance. Even with `--delete`, the built-in Administrator and Public roles and any policy with admin access are never deleted; they are reported as skipped. ### `rules:diff` diff --git a/docs/guide/rules-sync.md b/docs/guide/rules-sync.md index 81babd6..2177dbf 100644 --- a/docs/guide/rules-sync.md +++ b/docs/guide/rules-sync.md @@ -59,10 +59,12 @@ npx nuxt-directus-sdk rules:push rules.json # Only add new items, don't modify or delete existing ones npx nuxt-directus-sdk rules:push rules.json --add-only -# Add and update, but don't delete items missing locally -npx nuxt-directus-sdk rules:push rules.json --skip-deletes +# Also delete remote items that are missing locally (off by default) +npx nuxt-directus-sdk rules:push rules.json --delete ``` +Pushes never delete by default. With `--delete`, the built-in Administrator and Public roles and any policy with admin access are still protected and reported as skipped. + ### Environment Variables Instead of passing flags every time, set these in your `.env`: @@ -132,8 +134,10 @@ const result = await pushRules(client, localRules, { // Only create, don't update or delete addOnly: false, - // Don't delete items that exist remotely but not locally - skipDeletes: false, + // Leave items that exist remotely but not locally alone (default). + // Set to false to delete them; Administrator, Public and admin-access + // policies are never deleted. + skipDeletes: true, // Progress callback onProgress: (event) => {