Skip to content

Commit d1bb1d8

Browse files
authored
Merge pull request #204 from tractr/next
feat: add CLI flags for policy management
2 parents d84c1be + d3008a2 commit d1bb1d8

20 files changed

Lines changed: 343 additions & 75 deletions

File tree

.cursor/rules/project-overview.mdc

Lines changed: 0 additions & 56 deletions
This file was deleted.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33
node_modules/
44
dist/
55
.env
6+
.claude/

CLAUDE.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Repository Overview
2+
3+
Monorepo providing a Directus endpoint extension, a CLI for syncing schema/data, an e2e test harness, and documentation.
4+
5+
## High-level flow
6+
7+
- The CLI connects to a Directus instance and orchestrates pull/diff/push/seed/specs operations.
8+
- The API extension exposes endpoints used by the CLI for SyncID↔LocalID mapping and helpers.
9+
- E2E tests spin up a Directus server locally and validate the full flow (CLI + extension) end-to-end.
10+
11+
## Packages
12+
13+
### `packages/api` — Directus endpoint extension
14+
- Exposes `/directus-extension-sync` routes for SyncID mapping and helpers (e.g., permissions deduplication).
15+
- Uses Express-style router via Directus SDK, Zod validation, and Knex for a small mapping table.
16+
- Key files: `src/index.ts` (routes), `src/api/*` (middleware/helpers), `src/database/*` (IdMapper, Permissions).
17+
18+
### `packages/cli` — Directus Sync CLI
19+
- Orchestrates schema and system collections synchronization; supports seed data and API specifications dump.
20+
- Structure:
21+
- `services/config`: options resolution (defaults → config file → CLI flags), Zod validation.
22+
- `services/collections`: per-collection pull/diff/push with id mapping.
23+
- `services/snapshot`: schema snapshot pull/diff/push.
24+
- `services/seed`: seed files loader/differ/pusher with dynamic id mapping.
25+
- `services/specifications`: GraphQL/OpenAPI dump.
26+
- `services/*-client.ts`: Directus SDK client, extension HTTP client, health checks.
27+
- `commands` + `program.ts`: command wiring and execution.
28+
- Technologies: TypeScript, typedi (DI), pino (logging), Zod, @directus/sdk.
29+
30+
### `packages/e2e` — End-to-end tests
31+
- Jasmine-based tests against a locally started Directus server.
32+
- Helpers start the server, create a Directus client, and run CLI commands programmatically, asserting logs and state.
33+
- See `spec/helpers/sdk/*`, `spec/pull-diff-push/*`.
34+
35+
### `website` — Documentation
36+
- Docusaurus site documenting installation, features, and how the sync works.
37+
- When updating the CLI help outputs, build the whole project (`npm run build` at the root), then run `npm run update-help-outputs` to update the help outputs in the website.
38+
39+
## Other top-level
40+
41+
- `docker/`: build/publish scripts and Dockerfile for distributing Directus with the extension.
42+
- `lerna.json`: monorepo management.
43+
- `DOCUMENTATION.md`, `CHANGELOG.md`, etc.: project docs and changelog.
44+
45+
## Conventions
46+
47+
- TypeScript across packages; DI via `typedi`; logging via `pino`; validation via `zod`.
48+
- Keep business logic in services; commands/endpoints orchestrate only.
49+
- Follow per-package rules under `.cursor/rules/` for detailed guidance.
50+
51+
## Checks
52+
53+
- Before committing, run `npm run lint` to run the linter and fix any issues.
54+
- Before pushing, run `npm run test` to run the tests and fix any issues.
55+
- Before pushing, run `npm run build` to build the project and fix any issues.
56+
- Before pushing, run `npm run docs:update` to update the help outputs in the website.

packages/cli/src/lib/program.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ function cleanProgramOptions(programOptions: Record<string, unknown>) {
2626
* Remove some default values from the command options that overrides the config file
2727
*/
2828
function cleanCommandOptions(commandOptions: Record<string, unknown>) {
29+
if (commandOptions.collections === true) {
30+
delete commandOptions.collections;
31+
}
2932
if (commandOptions.snapshot === true) {
3033
delete commandOptions.snapshot;
3134
}
@@ -35,6 +38,9 @@ function cleanCommandOptions(commandOptions: Record<string, unknown>) {
3538
if (commandOptions.specs === true) {
3639
delete commandOptions.specs;
3740
}
41+
if (commandOptions.syncPolicyRoles === true) {
42+
delete commandOptions.syncPolicyRoles;
43+
}
3844
return commandOptions;
3945
}
4046

@@ -139,6 +145,10 @@ export function createProgram() {
139145
'--no-collections',
140146
`should pull and push the collections (default "${DefaultConfig.collections}")`,
141147
);
148+
const noSyncPolicyRolesOption = new Option(
149+
'--no-sync-policy-roles',
150+
`should sync the role ↔ policy attachments (directus_access entries linking roles and policies). Disable to leave existing role-policy assignments on the target untouched (default "${DefaultConfig.syncPolicyRoles}")`,
151+
);
142152
const preserveIdsOption = new Option(
143153
'--preserve-ids <preserveIds>',
144154
`comma separated list of collections that preserve their original ids (default to none). Use "*" or "all" to preserve all ids, if applicable.`,
@@ -194,6 +204,7 @@ export function createProgram() {
194204
.addOption(excludeCollectionsOption)
195205
.addOption(onlyCollectionsOption)
196206
.addOption(noCollectionsOption)
207+
.addOption(noSyncPolicyRolesOption)
197208
.addOption(preserveIdsOption)
198209
.addOption(snapshotPathOption)
199210
.addOption(noSnapshotOption)
@@ -212,6 +223,7 @@ export function createProgram() {
212223
.addOption(excludeCollectionsOption)
213224
.addOption(onlyCollectionsOption)
214225
.addOption(noCollectionsOption)
226+
.addOption(noSyncPolicyRolesOption)
215227
.addOption(snapshotPathOption)
216228
.addOption(noSnapshotOption)
217229
.addOption(noSplitOption)
@@ -227,6 +239,7 @@ export function createProgram() {
227239
.addOption(excludeCollectionsOption)
228240
.addOption(onlyCollectionsOption)
229241
.addOption(noCollectionsOption)
242+
.addOption(noSyncPolicyRolesOption)
230243
.addOption(preserveIdsOption)
231244
.addOption(snapshotPathOption)
232245
.addOption(noSnapshotOption)

packages/cli/src/lib/services/collections/policies/data-client.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,15 @@ import {
1717
import { LoggerService } from '../../logger';
1818
import { POLICIES_COLLECTION } from './constants';
1919
import deepmerge from 'deepmerge';
20+
import { ConfigService } from '../../config';
2021

2122
@Service()
2223
export class PoliciesDataClient extends DataClient<DirectusPolicy> {
23-
constructor(loggerService: LoggerService, migrationClient: MigrationClient) {
24+
constructor(
25+
loggerService: LoggerService,
26+
migrationClient: MigrationClient,
27+
protected readonly config: ConfigService,
28+
) {
2429
super(loggerService.getChild(POLICIES_COLLECTION), migrationClient);
2530
}
2631

@@ -33,9 +38,15 @@ export class PoliciesDataClient extends DataClient<DirectusPolicy> {
3338
}
3439

3540
protected getQueryCommand(query: Query<DirectusPolicy>) {
41+
// When role-policy attachments sync is disabled, omit the roles fields
42+
// entirely from the dump so they are neither tracked nor diffed.
43+
// See https://github.com/tractr/directus-sync/issues/199
44+
const extraFields = this.config.shouldSyncPolicyRoles()
45+
? ['*', 'roles.role', 'roles.sort']
46+
: ['*'];
3647
return readPolicies(
3748
deepmerge<Query<BaseDirectusPolicy>>(query, {
38-
fields: ['*', 'roles.role', 'roles.sort'],
49+
fields: extraFields,
3950
}),
4051
);
4152
}
@@ -44,6 +55,13 @@ export class PoliciesDataClient extends DataClient<DirectusPolicy> {
4455
itemId: string,
4556
diffItem: Partial<WithoutIdAndSyncId<DirectusPolicy>>,
4657
) {
58+
// When role-policy attachments sync is disabled, drop the roles diff
59+
// entirely so existing attachments on the target are left untouched.
60+
// See https://github.com/tractr/directus-sync/issues/199
61+
if (!this.config.shouldSyncPolicyRoles() && diffItem.roles) {
62+
const { roles: _ignored, ...rest } = diffItem as Partial<DirectusPolicy>;
63+
diffItem = rest as Partial<WithoutIdAndSyncId<DirectusPolicy>>;
64+
}
4765
// Explicit update of the roles field (many-to-many relation)
4866
// Issue : https://github.com/tractr/directus-sync/issues/148
4967
if (diffItem.roles) {

packages/cli/src/lib/services/collections/policies/data-mapper.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,27 @@ import { LoggerService } from '../../logger';
44
import { POLICIES_COLLECTION } from './constants';
55
import { DirectusPolicy } from './interfaces';
66
import { RolesIdMapperClient } from '../roles';
7+
import { ConfigService } from '../../config';
78

89
@Service()
910
export class PoliciesDataMapper extends DataMapper<DirectusPolicy> {
10-
protected fieldsToIgnore: Field<DirectusPolicy>[] = ['users', 'permissions'];
11-
protected idMappers: IdMappers<DirectusPolicy> = {
12-
roles: {
13-
// @ts-expect-error TODO: Bad SDK Typing
14-
role: Container.get(RolesIdMapperClient),
15-
},
16-
};
11+
protected fieldsToIgnore: Field<DirectusPolicy>[];
12+
protected idMappers: IdMappers<DirectusPolicy>;
1713

18-
constructor(loggerService: LoggerService) {
14+
constructor(loggerService: LoggerService, config: ConfigService) {
1915
super(loggerService.getChild(POLICIES_COLLECTION));
16+
17+
if (config.shouldSyncPolicyRoles()) {
18+
this.fieldsToIgnore = ['users', 'permissions'];
19+
this.idMappers = {
20+
roles: {
21+
// @ts-expect-error TODO: Bad SDK Typing
22+
role: Container.get(RolesIdMapperClient),
23+
},
24+
};
25+
} else {
26+
this.fieldsToIgnore = ['users', 'permissions', 'roles'];
27+
this.idMappers = {};
28+
}
2029
}
2130
}

packages/cli/src/lib/services/config/config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,11 @@ export class ConfigService {
177177
return list.filter((collection) => !exclude.includes(collection));
178178
}
179179

180+
@Cacheable()
181+
shouldSyncPolicyRoles() {
182+
return this.requireOptions('syncPolicyRoles');
183+
}
184+
180185
@Cacheable()
181186
shouldPreserveIds(collection: CollectionPreservableIdName) {
182187
const preserveIds = this.requireOptions('preserveIds');

packages/cli/src/lib/services/config/default-config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export const DefaultConfig: Pick<
1717
| 'excludeCollections'
1818
| 'onlyCollections'
1919
| 'collections'
20+
| 'syncPolicyRoles'
2021
| 'preserveIds'
2122
| 'snapshotPath'
2223
| 'snapshot'
@@ -42,6 +43,7 @@ export const DefaultConfig: Pick<
4243
excludeCollections: [],
4344
onlyCollections: [],
4445
collections: true,
46+
syncPolicyRoles: true,
4547
preserveIds: [],
4648
// Snapshot
4749
snapshotPath: 'snapshot',

packages/cli/src/lib/services/config/schema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ export const OptionsFields = {
106106
excludeCollections: z.array(CollectionEnum).optional(),
107107
onlyCollections: z.array(CollectionEnum).optional(),
108108
collections: z.boolean(),
109+
syncPolicyRoles: z.boolean(),
109110
preserveIds: z.union([
110111
z.array(CollectionPreservableIdEnum).optional(),
111112
z.enum(['all', '*']),
@@ -162,6 +163,7 @@ export const ConfigFileOptionsSchema = z.object({
162163
excludeCollections: OptionsFields.excludeCollections.optional(),
163164
onlyCollections: OptionsFields.onlyCollections.optional(),
164165
collections: OptionsFields.collections.optional(),
166+
syncPolicyRoles: OptionsFields.syncPolicyRoles.optional(),
165167
preserveIds: OptionsFields.preserveIds.optional(),
166168
// Snapshot config
167169
snapshotPath: OptionsFields.snapshotPath.optional(),
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module.exports = {
2+
collections: false,
3+
};

0 commit comments

Comments
 (0)