Skip to content
Merged
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
9 changes: 6 additions & 3 deletions docs/cli/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand All @@ -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`

Expand Down
12 changes: 8 additions & 4 deletions docs/guide/rules-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down Expand Up @@ -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) => {
Expand Down
18 changes: 16 additions & 2 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> Prefix for custom collection type names (generate-types)
--include <names> Comma-separated collection names to include (generate-types).
When set, only these collections (plus any they reference
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -411,7 +416,10 @@ async function main(): Promise<void> {
'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' },
Expand Down Expand Up @@ -459,6 +467,10 @@ async function main(): Promise<void> {
console.error('Usage: npx nuxt-directus-sdk rules:push <file> [--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,
Expand All @@ -467,7 +479,9 @@ async function main(): Promise<void> {
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
}
Expand Down
2 changes: 2 additions & 0 deletions src/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ export {
fetchRemoteRulesAsJson,
formatDiff,
formatPushResult,
isProtectedPolicy,
isProtectedRole,
pullRules,
pushRules,
} from './sync'
Expand Down
1 change: 1 addition & 0 deletions src/rules/sync/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
31 changes: 31 additions & 0 deletions src/rules/sync/protect.ts
Original file line number Diff line number Diff line change
@@ -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<DirectusRolePayload, 'name'>): boolean {
return PROTECTED_ROLE_NAMES.has(role.name)
}

export function isProtectedPolicy(policy: Pick<DirectusPolicyPayload, 'name' | 'admin_access'>): boolean {
if (policy.admin_access === true)
return true
return PROTECTED_POLICY_NAMES.has(policy.name)
}
37 changes: 35 additions & 2 deletions src/rules/sync/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -57,7 +58,7 @@ export async function pushRules<Schema>(
localRules: RulesConfig<Schema>,
options: PushOptions = {},
): Promise<PushResult> {
const { addOnly = false, skipDeletes = false, onProgress } = options
const { addOnly = false, skipDeletes = true, onProgress } = options

const result: PushResult = {
success: true,
Expand Down Expand Up @@ -331,7 +332,8 @@ export async function pushRules<Schema>(
}
}

// 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')
Expand Down Expand Up @@ -369,6 +371,16 @@ export async function pushRules<Schema>(
total: rolesToDelete.length,
})

if (change.remote && isProtectedRole(change.remote)) {
result.roles.push({
type: 'skipped',
name: change.name,
id: change.remote.id,
reason: `Protected role "${change.name}" was not deleted`,
})
continue
}

try {
await client.request(deleteRole(change.remote!.id!))
result.roles.push({ type: 'deleted', name: change.name, id: change.remote!.id })
Expand All @@ -394,6 +406,16 @@ export async function pushRules<Schema>(
total: policiesToDelete.length,
})

if (change.remote && isProtectedPolicy(change.remote)) {
result.policies.push({
type: 'skipped',
name: change.name,
id: change.remote.id,
reason: `Protected policy "${change.name}" was not deleted`,
})
continue
}

try {
await client.request(deletePolicy(change.remote!.id!))
result.policies.push({ type: 'deleted', name: change.name, id: change.remote!.id })
Expand Down Expand Up @@ -435,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:')
Expand Down
8 changes: 7 additions & 1 deletion src/rules/sync/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -103,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 */
Expand Down
33 changes: 33 additions & 0 deletions test/rules/protect.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
27 changes: 27 additions & 0 deletions test/rules/sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading