directus-template-cli 0.7.8 → 0.8.0

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.
Files changed (55) hide show
  1. package/README.md +134 -37
  2. package/dist/commands/apply.d.ts +5 -0
  3. package/dist/commands/apply.js +32 -68
  4. package/dist/commands/extract.d.ts +30 -0
  5. package/dist/commands/extract.js +14 -5
  6. package/dist/commands/init.d.ts +4 -0
  7. package/dist/commands/init.js +90 -87
  8. package/dist/lib/extract/expand-deep-plan.d.ts +2 -0
  9. package/dist/lib/extract/expand-deep-plan.js +54 -0
  10. package/dist/lib/extract/expand-schema-plan.d.ts +2 -0
  11. package/dist/lib/extract/expand-schema-plan.js +55 -0
  12. package/dist/lib/extract/extract-assets.js +19 -6
  13. package/dist/lib/extract/extract-collections.d.ts +2 -1
  14. package/dist/lib/extract/extract-collections.js +5 -2
  15. package/dist/lib/extract/extract-content.d.ts +2 -1
  16. package/dist/lib/extract/extract-content.js +108 -13
  17. package/dist/lib/extract/extract-fields.d.ts +2 -1
  18. package/dist/lib/extract/extract-fields.js +5 -5
  19. package/dist/lib/extract/extract-relations.d.ts +2 -1
  20. package/dist/lib/extract/extract-relations.js +6 -4
  21. package/dist/lib/extract/index.d.ts +2 -1
  22. package/dist/lib/extract/index.js +67 -29
  23. package/dist/lib/init/index.d.ts +15 -0
  24. package/dist/lib/init/index.js +29 -13
  25. package/dist/lib/load/apply-flags.d.ts +5 -2
  26. package/dist/lib/load/apply-flags.js +0 -50
  27. package/dist/lib/load/finalize-collections.d.ts +2 -0
  28. package/dist/lib/load/finalize-collections.js +28 -0
  29. package/dist/lib/load/finalize-fields.d.ts +2 -0
  30. package/dist/lib/load/finalize-fields.js +25 -0
  31. package/dist/lib/load/index.js +38 -18
  32. package/dist/lib/load/load-collections.d.ts +2 -1
  33. package/dist/lib/load/load-collections.js +17 -30
  34. package/dist/lib/load/load-data.d.ts +3 -1
  35. package/dist/lib/load/load-data.js +47 -34
  36. package/dist/lib/load/load-relations.d.ts +2 -1
  37. package/dist/lib/load/load-relations.js +17 -7
  38. package/dist/lib/template-plan/collections.d.ts +4 -0
  39. package/dist/lib/template-plan/collections.js +26 -0
  40. package/dist/lib/template-plan/flags.d.ts +18 -0
  41. package/dist/lib/template-plan/flags.js +61 -0
  42. package/dist/lib/template-plan/index.d.ts +16 -0
  43. package/dist/lib/template-plan/index.js +77 -0
  44. package/dist/lib/template-plan/junctions.d.ts +10 -0
  45. package/dist/lib/template-plan/junctions.js +19 -0
  46. package/dist/lib/template-plan/metadata-plan.d.ts +2 -0
  47. package/dist/lib/template-plan/metadata-plan.js +36 -0
  48. package/dist/lib/template-plan/metadata.d.ts +5 -0
  49. package/dist/lib/template-plan/metadata.js +42 -0
  50. package/dist/lib/template-plan/types.d.ts +34 -0
  51. package/dist/lib/template-plan/types.js +1 -0
  52. package/oclif.manifest.json +173 -16
  53. package/package.json +1 -1
  54. package/dist/lib/load/update-required-fields.d.ts +0 -1
  55. package/dist/lib/load/update-required-fields.js +0 -20
@@ -62,76 +62,67 @@ export default class InitCommand extends BaseCommand {
62
62
  * @returns Promise that resolves when the command is complete.
63
63
  */
64
64
  async run() {
65
- const { args, flags } = await this.parse(InitCommand);
65
+ const { args, flags, metadata } = await this.parse(InitCommand);
66
66
  const typedFlags = flags;
67
67
  const typedArgs = args;
68
+ const explicitFlags = {
69
+ gitInit: !metadata.flags.gitInit?.setFromDefault,
70
+ installDeps: !metadata.flags.installDeps?.setFromDefault,
71
+ };
68
72
  // Set the target directory and create it if it doesn't exist
69
73
  this.targetDir = path.resolve(args.directory);
70
- await this.runInteractive(typedFlags, typedArgs);
74
+ await this.runInteractive(typedFlags, typedArgs, explicitFlags);
71
75
  }
72
- /**
73
- * Interactive mode: prompts the user for each piece of info, with added template checks.
74
- * @param flags - The flags passed to the command.
75
- * @param args - The arguments passed to the command.
76
- * @returns void
77
- */
78
- async runInteractive(flags, args) {
79
- // Show animated intro
80
- await animatedBunny('Let\'s create a new Directus project!');
81
- intro(`${chalk.bgHex(DIRECTUS_PURPLE).white.bold('Directus Template CLI')} - Create Project`);
82
- // Check Docker availability before proceeding
83
- const { createDocker } = await import('../services/docker.js');
84
- const { DOCKER_CONFIG } = await import('../lib/init/config.js');
85
- const dockerService = createDocker(DOCKER_CONFIG);
86
- const dockerStatus = await dockerService.checkDocker();
87
- if (!dockerStatus.installed || !dockerStatus.running) {
88
- cancel(dockerStatus.message || 'Docker is required to initialize a Directus project.');
89
- process.exit(1);
76
+ async confirmBooleanFlag(message) {
77
+ const response = await confirm({
78
+ initialValue: true,
79
+ message,
80
+ });
81
+ if (isCancel(response)) {
82
+ cancel('Project creation cancelled.');
83
+ process.exit(0);
90
84
  }
91
- // Create GitHub service
92
- const github = createGitHub();
93
- // If no dir is provided, ask for it
94
- if (!args.directory || args.directory === '.') {
95
- let dirResponse = await text({
96
- message: 'Enter the directory to create the project in:',
97
- placeholder: './my-directus-project',
98
- });
99
- if (isCancel(dirResponse)) {
100
- cancel('Project creation cancelled.');
101
- process.exit(0);
102
- }
103
- // If there's no response, set a default
104
- if (!dirResponse) {
105
- clackLog.warn('No directory provided, using default: ./my-directus-project');
106
- dirResponse = './my-directus-project';
107
- }
108
- this.targetDir = dirResponse;
85
+ return response;
86
+ }
87
+ async confirmOverwriteDirectory(flags) {
88
+ if (!fs.existsSync(this.targetDir) || flags.overwriteDir)
89
+ return Boolean(flags.overwriteDir);
90
+ const overwriteDirResponse = await confirm({
91
+ initialValue: false,
92
+ message: 'Directory already exists. Would you like to overwrite it?',
93
+ });
94
+ if (isCancel(overwriteDirResponse) || overwriteDirResponse === false) {
95
+ cancel('Project creation cancelled.');
96
+ process.exit(0);
109
97
  }
110
- if (fs.existsSync(this.targetDir) && !flags.overwriteDir) {
111
- const overwriteDirResponse = await confirm({
112
- initialValue: false,
113
- message: 'Directory already exists. Would you like to overwrite it?',
114
- });
115
- if (isCancel(overwriteDirResponse) || overwriteDirResponse === false) {
116
- cancel('Project creation cancelled.');
117
- process.exit(0);
118
- }
119
- if (overwriteDirResponse) {
120
- flags.overwriteDir = true;
121
- }
98
+ return true;
99
+ }
100
+ async promptForTargetDirectory(args) {
101
+ if (args.directory && args.directory !== '.')
102
+ return;
103
+ let dirResponse = await text({
104
+ message: 'Enter the directory to create the project in:',
105
+ placeholder: './my-directus-project',
106
+ });
107
+ if (isCancel(dirResponse)) {
108
+ cancel('Project creation cancelled.');
109
+ process.exit(0);
122
110
  }
123
- // 1. Fetch available templates (now returns Array<{id: string, name: string, description?: string}>)
124
- const availableTemplates = await github.getTemplates();
125
- // 2. Prompt for template if not provided
126
- let { template } = flags; // This will store the chosen template ID
127
- let chosenTemplateObject;
111
+ // If there's no response, set a default
112
+ if (!dirResponse) {
113
+ clackLog.warn('No directory provided, using default: ./my-directus-project');
114
+ dirResponse = './my-directus-project';
115
+ }
116
+ this.targetDir = dirResponse;
117
+ }
118
+ async promptForValidTemplate(template, availableTemplates) {
128
119
  if (!template) {
129
120
  const templateResponse = await select({
130
121
  message: 'Which Directus backend template would you like to use?',
131
122
  options: availableTemplates.map(tmpl => ({
132
- hint: tmpl.description, // Show the description as a hint
133
- label: tmpl.name, // Display the friendly name
134
- value: tmpl.id, // The value submitted will be the ID (directory name)
123
+ hint: tmpl.description,
124
+ label: tmpl.name,
125
+ value: tmpl.id,
135
126
  })),
136
127
  });
137
128
  if (isCancel(templateResponse)) {
@@ -140,14 +131,9 @@ export default class InitCommand extends BaseCommand {
140
131
  }
141
132
  template = templateResponse;
142
133
  }
143
- // Find the chosen template object for potential future use (e.g., display name later)
144
- chosenTemplateObject = availableTemplates.find(t => t.id === template);
145
- // 3. Validate that the template exists in the available list
146
- const isDirectUrl = template?.startsWith('http');
147
- // Validate against the 'id' property of the template objects
148
- while (!isDirectUrl && !availableTemplates.some(t => t.id === template)) {
149
- // Keep the warning message simple or refer back to the list shown in the prompt
134
+ while (!template.startsWith('http') && !availableTemplates.some(t => t.id === template)) {
150
135
  clackLog.warn(`Template ID "${template}" is not valid. Please choose from the list provided or enter a direct GitHub URL.`);
136
+ // eslint-disable-next-line no-await-in-loop
151
137
  const templateNameResponse = await text({
152
138
  message: 'Please enter a valid template ID, a direct GitHub URL, or Ctrl+C to cancel:',
153
139
  });
@@ -156,8 +142,37 @@ export default class InitCommand extends BaseCommand {
156
142
  process.exit(0);
157
143
  }
158
144
  template = templateNameResponse;
159
- chosenTemplateObject = availableTemplates.find(t => t.id === template); // Update chosen object after re-entry
160
145
  }
146
+ return template;
147
+ }
148
+ /**
149
+ * Interactive mode: prompts the user for each piece of info, with added template checks.
150
+ * @param flags - The flags passed to the command.
151
+ * @param args - The arguments passed to the command.
152
+ * @returns void
153
+ */
154
+ async runInteractive(flags, args, explicitFlags) {
155
+ // Show animated intro
156
+ await animatedBunny('Let\'s create a new Directus project!');
157
+ intro(`${chalk.bgHex(DIRECTUS_PURPLE).white.bold('Directus Template CLI')} - Create Project`);
158
+ // Check Docker availability before proceeding
159
+ const { createDocker } = await import('../services/docker.js');
160
+ const { DOCKER_CONFIG } = await import('../lib/init/config.js');
161
+ const dockerService = createDocker(DOCKER_CONFIG);
162
+ const dockerStatus = await dockerService.checkDocker();
163
+ if (!dockerStatus.installed || !dockerStatus.running) {
164
+ cancel(dockerStatus.message || 'Docker is required to initialize a Directus project.');
165
+ process.exit(1);
166
+ }
167
+ // Create GitHub service
168
+ const github = createGitHub();
169
+ // If no dir is provided, ask for it
170
+ await this.promptForTargetDirectory(args);
171
+ const overwriteDir = await this.confirmOverwriteDirectory(flags);
172
+ // 1. Fetch available templates (now returns Array<{id: string, name: string, description?: string}>)
173
+ const availableTemplates = await github.getTemplates();
174
+ // 2. Prompt for template if not provided, then validate it against known templates or a direct URL.
175
+ const template = await this.promptForValidTemplate(flags.template, availableTemplates);
161
176
  flags.template = template; // Ensure the flag stores the ID
162
177
  // Download the template to a temporary directory to read its configuration
163
178
  const tempDir = path.join(os.tmpdir(), `directus-template-${Date.now()}`);
@@ -170,7 +185,7 @@ export default class InitCommand extends BaseCommand {
170
185
  // Read template configuration
171
186
  const templateInfo = readTemplateConfig(tempDir);
172
187
  // 4. If template has frontends and user hasn't specified a valid one, ask from the list
173
- if (templateInfo?.frontendOptions.length > 0 && (!chosenFrontend || !templateInfo.frontendOptions.find(f => f.id === chosenFrontend))) {
188
+ if (templateInfo?.frontendOptions.length > 0 && (!chosenFrontend || !templateInfo.frontendOptions.some(f => f.id === chosenFrontend))) {
174
189
  const frontendResponse = await select({
175
190
  message: 'Which frontend framework do you want to use?',
176
191
  options: templateInfo.frontendOptions.map(frontend => ({
@@ -194,24 +209,12 @@ export default class InitCommand extends BaseCommand {
194
209
  fs.rmSync(tempDir, { force: true, recursive: true });
195
210
  }
196
211
  }
197
- const installDepsResponse = await confirm({
198
- initialValue: true,
199
- message: 'Would you like to install project dependencies automatically?',
200
- });
201
- if (isCancel(installDepsResponse)) {
202
- cancel('Project creation cancelled.');
203
- process.exit(0);
204
- }
205
- const installDeps = installDepsResponse;
206
- const initGitResponse = await confirm({
207
- initialValue: true,
208
- message: 'Initialize a new Git repository?',
209
- });
210
- if (isCancel(initGitResponse)) {
211
- cancel('Project creation cancelled.');
212
- process.exit(0);
213
- }
214
- const initGit = initGitResponse;
212
+ const installDeps = explicitFlags.installDeps
213
+ ? flags.installDeps ?? true
214
+ : await this.confirmBooleanFlag('Would you like to install project dependencies automatically?');
215
+ const initGit = explicitFlags.gitInit
216
+ ? flags.gitInit ?? true
217
+ : await this.confirmBooleanFlag('Initialize a new Git repository?');
215
218
  // Track the command start unless telemetry is disabled
216
219
  if (!flags.disableTelemetry) {
217
220
  await track({
@@ -235,7 +238,7 @@ export default class InitCommand extends BaseCommand {
235
238
  frontend: chosenFrontend,
236
239
  gitInit: initGit,
237
240
  installDeps,
238
- overwriteDir: flags.overwriteDir,
241
+ overwriteDir,
239
242
  template,
240
243
  },
241
244
  });
@@ -249,7 +252,7 @@ export default class InitCommand extends BaseCommand {
249
252
  frontend: chosenFrontend,
250
253
  gitInit: initGit,
251
254
  installDeps,
252
- overwriteDir: flags.overwriteDir,
255
+ overwriteDir,
253
256
  template,
254
257
  },
255
258
  lifecycle: 'complete',
@@ -0,0 +1,2 @@
1
+ import { type TemplatePlan } from '../template-plan/index.js';
2
+ export declare function expandDeepPlan(plan: TemplatePlan): Promise<TemplatePlan>;
@@ -0,0 +1,54 @@
1
+ import { readCollections, readRelations } from '@directus/sdk';
2
+ import { ux } from '@oclif/core';
3
+ import { api } from '../sdk.js';
4
+ import { includesCollection } from '../template-plan/index.js';
5
+ export async function expandDeepPlan(plan) {
6
+ if (!plan.partial || plan.relationStrategy !== 'deep' || !plan.collections)
7
+ return plan;
8
+ const collections = await api.client.request(readCollections());
9
+ const availableCollections = collections
10
+ .filter((collection) => !collection.collection.startsWith('directus_', 0))
11
+ .filter((collection) => collection.schema !== null)
12
+ .map((collection) => collection.collection)
13
+ .filter((collection) => includesCollection(collection, { ...plan, collections: undefined }));
14
+ const available = new Set(availableCollections);
15
+ const missingCollections = plan.collections.filter((collection) => !available.has(collection));
16
+ if (missingCollections.length > 0) {
17
+ ux.warn(`Requested collections not found or excluded: ${missingCollections.join(', ')}`);
18
+ }
19
+ const selected = new Set(plan.collections.filter((collection) => available.has(collection)));
20
+ const relations = (await api.client.request(readRelations()));
21
+ let changed = true;
22
+ while (changed) {
23
+ changed = false;
24
+ const candidates = [];
25
+ for (const relation of relations) {
26
+ if (selected.has(relation.collection) && relation.related_collection) {
27
+ candidates.push(relation.related_collection);
28
+ }
29
+ if (relation.related_collection && selected.has(relation.related_collection)) {
30
+ candidates.push(relation.collection);
31
+ }
32
+ if (selected.has(relation.collection) && relation.meta?.one_allowed_collections) {
33
+ candidates.push(...relation.meta.one_allowed_collections);
34
+ }
35
+ }
36
+ for (const collection of candidates) {
37
+ if (!available.has(collection))
38
+ continue;
39
+ if (selected.has(collection))
40
+ continue;
41
+ selected.add(collection);
42
+ changed = true;
43
+ }
44
+ }
45
+ const expandedCollections = [...selected];
46
+ const addedCollections = expandedCollections.filter((collection) => !plan.collections?.includes(collection));
47
+ if (addedCollections.length > 0) {
48
+ ux.warn(`Deep relation strategy expanded collections: ${addedCollections.join(', ')}`);
49
+ }
50
+ return {
51
+ ...plan,
52
+ collections: expandedCollections,
53
+ };
54
+ }
@@ -0,0 +1,2 @@
1
+ import { type TemplatePlan } from '../template-plan/index.js';
2
+ export declare function expandSchemaPlan(plan: TemplatePlan): Promise<TemplatePlan>;
@@ -0,0 +1,55 @@
1
+ import { readCollections, readRelations } from '@directus/sdk';
2
+ import { ux } from '@oclif/core';
3
+ import { api } from '../sdk.js';
4
+ import { includesCollection } from '../template-plan/index.js';
5
+ export async function expandSchemaPlan(plan) {
6
+ if (!plan.partial || !plan.collections)
7
+ return plan;
8
+ const collections = (await api.client.request(readCollections()));
9
+ const availableCollections = collections
10
+ .filter((collection) => !collection.collection.startsWith('directus_', 0))
11
+ .map((collection) => collection.collection)
12
+ .filter((collection) => includesCollection(collection, { ...plan, collections: undefined }));
13
+ const collectionMap = new Map(collections.map((collection) => [collection.collection, collection]));
14
+ const available = new Set(availableCollections);
15
+ const selected = new Set(plan.collections.filter((collection) => available.has(collection)));
16
+ const relations = (await api.client.request(readRelations()));
17
+ let changed = true;
18
+ while (changed) {
19
+ changed = false;
20
+ const candidates = [];
21
+ for (const collection of selected) {
22
+ const group = collectionMap.get(collection)?.meta?.group;
23
+ if (group)
24
+ candidates.push(group);
25
+ }
26
+ for (const relation of relations) {
27
+ if (selected.has(relation.collection) && relation.related_collection) {
28
+ candidates.push(relation.related_collection);
29
+ }
30
+ if (relation.related_collection && selected.has(relation.related_collection)) {
31
+ candidates.push(relation.collection);
32
+ }
33
+ if (selected.has(relation.collection) && relation.meta?.one_allowed_collections) {
34
+ candidates.push(...relation.meta.one_allowed_collections);
35
+ }
36
+ }
37
+ for (const collection of candidates) {
38
+ if (!available.has(collection))
39
+ continue;
40
+ if (selected.has(collection))
41
+ continue;
42
+ selected.add(collection);
43
+ changed = true;
44
+ }
45
+ }
46
+ const schemaCollections = [...selected];
47
+ const addedCollections = schemaCollections.filter((collection) => !plan.collections?.includes(collection));
48
+ if (addedCollections.length > 0) {
49
+ ux.warn(`Schema scope expanded collections: ${addedCollections.join(', ')}`);
50
+ }
51
+ return {
52
+ ...plan,
53
+ schemaCollections,
54
+ };
55
+ }
@@ -5,10 +5,13 @@ import path from 'pathe';
5
5
  import { DIRECTUS_PINK } from '../constants.js';
6
6
  import { api } from '../sdk.js';
7
7
  import catchError from '../utils/catch-error.js';
8
- async function getAssetList() {
9
- return api.client.request(readFiles({ limit: -1 }));
8
+ // Keep asset pages conservative because each item may trigger a binary download.
9
+ const PAGE_SIZE = 100;
10
+ async function getAssetPage(page) {
11
+ return api.client.request(readFiles({ limit: PAGE_SIZE, page }));
10
12
  }
11
13
  async function downloadFile(file, dir) {
14
+ // eslint-disable-next-line n/no-unsupported-features/node-builtins
12
15
  const response = await api.client.request(() => ({
13
16
  method: 'GET',
14
17
  path: `/assets/${file.id}`,
@@ -29,10 +32,20 @@ export async function downloadAllFiles(dir) {
29
32
  if (path && !fs.existsSync(fullPath)) {
30
33
  fs.mkdirSync(fullPath, { recursive: true });
31
34
  }
32
- const fileList = await getAssetList();
33
- await Promise.all(fileList.map(file => downloadFile(file, dir).catch(error => {
34
- catchError(`Error downloading ${file.filename_disk}: ${error.message}`);
35
- })));
35
+ let page = 1;
36
+ while (true) {
37
+ ux.action.status = `Downloading assets page ${page}`;
38
+ // Page asset metadata sequentially and finish each page before fetching the next, to avoid queuing all downloads at once.
39
+ // eslint-disable-next-line no-await-in-loop
40
+ const fileList = await getAssetPage(page);
41
+ // eslint-disable-next-line no-await-in-loop
42
+ await Promise.all(fileList.map((file) => downloadFile(file, dir).catch((error) => {
43
+ catchError(`Error downloading ${file.filename_disk}: ${error.message}`);
44
+ })));
45
+ if (fileList.length < PAGE_SIZE)
46
+ break;
47
+ page++;
48
+ }
36
49
  }
37
50
  catch (error) {
38
51
  catchError(error);
@@ -1,4 +1,5 @@
1
+ import { type TemplatePlan } from '../template-plan/index.js';
1
2
  /**
2
3
  * Extract collections from the Directus instance
3
4
  */
4
- export default function extractCollections(dir: string): Promise<void>;
5
+ export default function extractCollections(dir: string, plan?: TemplatePlan): Promise<void>;
@@ -2,16 +2,19 @@ import { readCollections } from '@directus/sdk';
2
2
  import { ux } from '@oclif/core';
3
3
  import { DIRECTUS_PINK } from '../constants.js';
4
4
  import { api } from '../sdk.js';
5
+ import { includesSchemaCollection } from '../template-plan/index.js';
5
6
  import catchError from '../utils/catch-error.js';
6
7
  import writeToFile from '../utils/write-to-file.js';
7
8
  /**
8
9
  * Extract collections from the Directus instance
9
10
  */
10
- export default async function extractCollections(dir) {
11
+ export default async function extractCollections(dir, plan) {
11
12
  ux.action.start(ux.colorize(DIRECTUS_PINK, 'Extracting collections'));
12
13
  try {
13
14
  const response = await api.client.request(readCollections());
14
- const collections = response.filter(collection => !collection.collection.startsWith('directus_'));
15
+ const collections = response
16
+ .filter((collection) => !collection.collection.startsWith('directus_'))
17
+ .filter((collection) => includesSchemaCollection(collection.collection, plan));
15
18
  await writeToFile('collections', collections, dir);
16
19
  }
17
20
  catch (error) {
@@ -1 +1,2 @@
1
- export declare function extractContent(dir: string): Promise<void>;
1
+ import { type TemplatePlan, type TemplateWarning } from '../template-plan/index.js';
2
+ export declare function extractContent(dir: string, plan?: TemplatePlan): Promise<TemplateWarning[]>;
@@ -1,33 +1,128 @@
1
- import { readCollections, readItems } from '@directus/sdk';
2
- import { ux } from '@oclif/core';
1
+ import { readCollections, readItems, readRelations } from '@directus/sdk';
2
+ import { Errors, ux } from '@oclif/core';
3
3
  import { DIRECTUS_PINK } from '../constants.js';
4
4
  import { api } from '../sdk.js';
5
+ import { getBrokenJunctionCollections, includesCollection, } from '../template-plan/index.js';
5
6
  import catchError from '../utils/catch-error.js';
6
7
  import writeToFile from '../utils/write-to-file.js';
7
- async function getCollections() {
8
+ // Content items are JSON-only, so pages can be larger than asset download pages.
9
+ const PAGE_SIZE = 500;
10
+ async function getCollections(relations, plan) {
8
11
  const response = await api.client.request(readCollections());
12
+ const brokenJunctions = getBrokenJunctionCollections(relations, plan);
9
13
  return response
10
- .filter(item => !item.collection.startsWith('directus_', 0))
11
- .filter(item => item.schema != null)
12
- .map(i => i.collection);
14
+ .filter((item) => !item.collection.startsWith('directus_', 0))
15
+ .filter((item) => item.schema !== null)
16
+ .map((i) => i.collection)
17
+ .filter((collection) => includesCollection(collection, plan))
18
+ .filter((collection) => !brokenJunctions.has(collection));
13
19
  }
14
- async function getDataFromCollection(collection, dir) {
20
+ async function getCollectionItems(collection) {
21
+ const items = [];
22
+ let page = 1;
23
+ while (true) {
24
+ // eslint-disable-next-line no-await-in-loop
25
+ const response = (await api.client.request(readItems(collection, { limit: PAGE_SIZE, page })));
26
+ items.push(...response);
27
+ if (response.length < PAGE_SIZE)
28
+ break;
29
+ page++;
30
+ }
31
+ return items;
32
+ }
33
+ function getExcludedRelationFields(collection, relations, plan) {
34
+ if (!plan?.partial || plan.relationStrategy === 'deep')
35
+ return [];
36
+ const m2oFields = relations
37
+ .filter((relation) => relation.collection === collection)
38
+ .filter((relation) => Boolean(relation.related_collection))
39
+ .filter((relation) => !includesCollection(relation.related_collection, plan))
40
+ .map((relation) => ({
41
+ field: relation.field,
42
+ relatedCollection: relation.related_collection,
43
+ type: 'm2o',
44
+ }));
45
+ const aliasFields = relations
46
+ .filter((relation) => relation.related_collection === collection)
47
+ .filter((relation) => Boolean(relation.meta?.one_field))
48
+ .filter((relation) => !includesCollection(relation.collection, plan))
49
+ .map((relation) => ({
50
+ field: relation.meta.one_field,
51
+ relatedCollection: relation.collection,
52
+ type: 'alias',
53
+ }));
54
+ return [...m2oFields, ...aliasFields];
55
+ }
56
+ function hasValue(value) {
57
+ if (Array.isArray(value))
58
+ return value.length > 0;
59
+ return value !== null && value !== undefined;
60
+ }
61
+ function emptyExcludedRelations(items, relations) {
62
+ for (const item of items) {
63
+ for (const relation of relations) {
64
+ if (!(relation.field in item))
65
+ continue;
66
+ if (relation.type === 'alias') {
67
+ delete item[relation.field];
68
+ }
69
+ else {
70
+ item[relation.field] = null;
71
+ }
72
+ }
73
+ }
74
+ }
75
+ function getBrokenRelationWarnings(collection, items, relations) {
76
+ return relations
77
+ .map((relation) => ({
78
+ collection,
79
+ count: items.filter((item) => hasValue(item[relation.field])).length,
80
+ field: relation.field,
81
+ relatedCollection: relation.relatedCollection,
82
+ type: 'excluded_relation',
83
+ }))
84
+ .filter((warning) => warning.count > 0);
85
+ }
86
+ async function getDataFromCollection(collection, dir, relations, plan) {
15
87
  try {
16
- const response = await api.client.request(readItems(collection, { limit: -1 }));
88
+ ux.action.status = `Extracting content: ${collection}`;
89
+ const response = await getCollectionItems(collection);
90
+ const excludedRelations = getExcludedRelationFields(collection, relations, plan);
91
+ if (plan?.relationStrategy === 'empty') {
92
+ emptyExcludedRelations(response, excludedRelations);
93
+ }
94
+ const warnings = plan?.relationStrategy === 'preserve' ? getBrokenRelationWarnings(collection, response, excludedRelations) : [];
17
95
  await writeToFile(`${collection}`, response, `${dir}/content/`);
96
+ return warnings;
18
97
  }
19
98
  catch (error) {
20
- catchError(error);
99
+ catchError(error, {
100
+ context: { collection, function: 'getDataFromCollection' },
101
+ fatal: true,
102
+ });
21
103
  }
22
104
  }
23
- export async function extractContent(dir) {
105
+ export async function extractContent(dir, plan) {
24
106
  ux.action.start(ux.colorize(DIRECTUS_PINK, 'Extracting content'));
107
+ const warnings = [];
25
108
  try {
26
- const collections = await getCollections();
27
- await Promise.all(collections.map(collection => getDataFromCollection(collection, dir)));
109
+ const relations = (await api.client.request(readRelations()));
110
+ const collections = await getCollections(relations, plan);
111
+ for (const collection of collections) {
112
+ // Keep extraction sequential so the shared ux.action.status reflects the active collection.
113
+ // eslint-disable-next-line no-await-in-loop
114
+ const collectionWarnings = await getDataFromCollection(collection, dir, relations, plan);
115
+ warnings.push(...collectionWarnings);
116
+ }
28
117
  }
29
118
  catch (error) {
30
- catchError(error);
119
+ if (error instanceof Errors.CLIError)
120
+ throw error;
121
+ catchError(error, {
122
+ context: { function: 'extractContent' },
123
+ fatal: true,
124
+ });
31
125
  }
32
126
  ux.action.stop();
127
+ return warnings;
33
128
  }
@@ -1,4 +1,5 @@
1
+ import { type TemplatePlan } from '../template-plan/index.js';
1
2
  /**
2
3
  * Extract fields from the Directus instance
3
4
  */
4
- export default function extractFields(dir: string): Promise<void>;
5
+ export default function extractFields(dir: string, plan?: TemplatePlan): Promise<void>;
@@ -2,12 +2,13 @@ import { readFields } from '@directus/sdk';
2
2
  import { ux } from '@oclif/core';
3
3
  import { DIRECTUS_PINK } from '../constants.js';
4
4
  import { api } from '../sdk.js';
5
+ import { includesSchemaCollection } from '../template-plan/index.js';
5
6
  import catchError from '../utils/catch-error.js';
6
7
  import writeToFile from '../utils/write-to-file.js';
7
8
  /**
8
9
  * Extract fields from the Directus instance
9
10
  */
10
- export default async function extractFields(dir) {
11
+ export default async function extractFields(dir, plan) {
11
12
  ux.action.start(ux.colorize(DIRECTUS_PINK, 'Extracting fields'));
12
13
  try {
13
14
  const response = await api.client.request(readFields());
@@ -15,10 +16,9 @@ export default async function extractFields(dir) {
15
16
  throw new TypeError('Unexpected response format');
16
17
  }
17
18
  const fields = response
18
- .filter(
19
- // @ts-ignore
20
- (i) => i.meta && !i.meta.system)
21
- .map(i => {
19
+ .filter((i) => i.meta && !i.meta.system)
20
+ .filter((i) => includesSchemaCollection(i.collection, plan))
21
+ .map((i) => {
22
22
  if (i.meta) {
23
23
  delete i.meta.id;
24
24
  }
@@ -1,4 +1,5 @@
1
+ import { type TemplatePlan } from '../template-plan/index.js';
1
2
  /**
2
3
  * Extract relations from the Directus instance
3
4
  */
4
- export default function extractRelations(dir: string): Promise<void>;
5
+ export default function extractRelations(dir: string, plan?: TemplatePlan): Promise<void>;