directus-template-cli 0.8.0-partials.0 → 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.
@@ -50,9 +50,10 @@ export default class ApplyCommand extends BaseCommand {
50
50
  private runProgrammatic;
51
51
  /**
52
52
  * INTERACTIVE
53
- * Select a local template from the given directory
54
- * @param localTemplateDir - The local template directory path
53
+ * Select a template from the given GitHub repository
54
+ * @param ghTemplateUrl - The GitHub repository URL
55
55
  * @returns {Promise<Template>} - Returns the selected template
56
56
  */
57
+ private selectGithubTemplate;
57
58
  private selectLocalTemplate;
58
59
  }
@@ -10,7 +10,7 @@ import * as templatePlanFlags from '../lib/template-plan/flags.js';
10
10
  import { animatedBunny } from '../lib/utils/animated-bunny.js';
11
11
  import { getDirectusEmailAndPassword, getDirectusToken, getDirectusUrl, initializeDirectusApi, } from '../lib/utils/auth.js';
12
12
  import catchError from '../lib/utils/catch-error.js';
13
- import { getCommunityTemplates, getGithubTemplate, getInteractiveLocalTemplate, getLocalTemplate, } from '../lib/utils/get-template.js';
13
+ import { getCommunityTemplates, getGithubTemplate, getInteractiveGithubTemplate, getInteractiveLocalTemplate, getLocalTemplate, } from '../lib/utils/get-template.js';
14
14
  import { logger } from '../lib/utils/logger.js';
15
15
  import openUrl from '../lib/utils/open-url.js';
16
16
  import { shutdown, track } from '../services/posthog.js';
@@ -110,7 +110,7 @@ export default class ApplyCommand extends BaseCommand {
110
110
  const ghTemplateUrl = await text({
111
111
  message: 'What is the public GitHub repository URL?',
112
112
  });
113
- template = await getGithubTemplate(ghTemplateUrl);
113
+ template = await this.selectGithubTemplate(ghTemplateUrl);
114
114
  break;
115
115
  }
116
116
  case 'local': {
@@ -267,10 +267,33 @@ export default class ApplyCommand extends BaseCommand {
267
267
  }
268
268
  /**
269
269
  * INTERACTIVE
270
- * Select a local template from the given directory
271
- * @param localTemplateDir - The local template directory path
270
+ * Select a template from the given GitHub repository
271
+ * @param ghTemplateUrl - The GitHub repository URL
272
272
  * @returns {Promise<Template>} - Returns the selected template
273
273
  */
274
+ async selectGithubTemplate(ghTemplateUrl) {
275
+ try {
276
+ const templates = await getInteractiveGithubTemplate(ghTemplateUrl);
277
+ if (templates.length === 1) {
278
+ return templates[0];
279
+ }
280
+ log.info('Multiple Directus templates found in this repository.');
281
+ const selectedTemplate = await select({
282
+ message: 'Select a template.',
283
+ options: templates.map(t => ({ label: t.templateName, value: t })),
284
+ });
285
+ return selectedTemplate;
286
+ }
287
+ catch (error) {
288
+ if (error instanceof Error) {
289
+ ux.error(error.message);
290
+ }
291
+ else {
292
+ ux.error('An unknown error occurred while getting the GitHub template.');
293
+ }
294
+ throw error;
295
+ }
296
+ }
274
297
  async selectLocalTemplate(localTemplateDir) {
275
298
  try {
276
299
  const templates = await getInteractiveLocalTemplate(localTemplateDir);
@@ -293,6 +316,7 @@ export default class ApplyCommand extends BaseCommand {
293
316
  else {
294
317
  ux.error('An unknown error occurred while getting the local template.');
295
318
  }
319
+ throw error;
296
320
  }
297
321
  }
298
322
  }
@@ -32,6 +32,10 @@ export default class InitCommand extends BaseCommand {
32
32
  * @returns Promise that resolves when the command is complete.
33
33
  */
34
34
  run(): Promise<void>;
35
+ private confirmBooleanFlag;
36
+ private confirmOverwriteDirectory;
37
+ private promptForTargetDirectory;
38
+ private promptForValidTemplate;
35
39
  /**
36
40
  * Interactive mode: prompts the user for each piece of info, with added template checks.
37
41
  * @param flags - The flags passed to the command.
@@ -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',
@@ -1,5 +1,5 @@
1
1
  import { readCollections, readItems, readRelations } from '@directus/sdk';
2
- import { ux } from '@oclif/core';
2
+ import { Errors, ux } from '@oclif/core';
3
3
  import { DIRECTUS_PINK } from '../constants.js';
4
4
  import { api } from '../sdk.js';
5
5
  import { getBrokenJunctionCollections, includesCollection, } from '../template-plan/index.js';
@@ -100,7 +100,6 @@ async function getDataFromCollection(collection, dir, relations, plan) {
100
100
  context: { collection, function: 'getDataFromCollection' },
101
101
  fatal: true,
102
102
  });
103
- throw error;
104
103
  }
105
104
  }
106
105
  export async function extractContent(dir, plan) {
@@ -110,12 +109,15 @@ export async function extractContent(dir, plan) {
110
109
  const relations = (await api.client.request(readRelations()));
111
110
  const collections = await getCollections(relations, plan);
112
111
  for (const collection of collections) {
112
+ // Keep extraction sequential so the shared ux.action.status reflects the active collection.
113
113
  // eslint-disable-next-line no-await-in-loop
114
114
  const collectionWarnings = await getDataFromCollection(collection, dir, relations, plan);
115
115
  warnings.push(...collectionWarnings);
116
116
  }
117
117
  }
118
118
  catch (error) {
119
+ if (error instanceof Errors.CLIError)
120
+ throw error;
119
121
  catchError(error, {
120
122
  context: { function: 'extractContent' },
121
123
  fatal: true,
@@ -1,4 +1,5 @@
1
1
  import { type DownloadTemplateResult } from 'giget';
2
+ import { type PackageManager } from 'nypm';
2
3
  import type { InitFlags } from '../../commands/init.js';
3
4
  export declare function init({ dir, flags }: {
4
5
  dir: string;
@@ -8,3 +9,17 @@ export declare function init({ dir, flags }: {
8
9
  frontendDir: string;
9
10
  template: DownloadTemplateResult;
10
11
  }>;
12
+ type DependencyInstaller = (options: {
13
+ cwd: string;
14
+ packageManager?: PackageManager;
15
+ silent: boolean;
16
+ }) => Promise<unknown>;
17
+ type InstallFrontendDependenciesOptions = {
18
+ frontendDir: string;
19
+ install?: DependencyInstaller;
20
+ onSkip?: () => void;
21
+ packageManager: null | PackageManager;
22
+ warn?: (message: string) => void;
23
+ };
24
+ export declare function installFrontendDependencies({ frontendDir, install, onSkip, packageManager, warn, }: InstallFrontendDependenciesOptions): Promise<boolean>;
25
+ export {};
@@ -133,21 +133,18 @@ export async function init({ dir, flags }) {
133
133
  // Install dependencies if requested
134
134
  if (flags.installDeps) {
135
135
  const s = spinner();
136
+ let dependenciesInstalled = true;
136
137
  s.start('Installing dependencies');
137
- try {
138
- if (fs.existsSync(frontendDir)) {
139
- await installDependencies({
140
- cwd: frontendDir,
141
- packageManager,
142
- silent: true,
143
- });
144
- }
138
+ if (fs.existsSync(frontendDir)) {
139
+ dependenciesInstalled = await installFrontendDependencies({
140
+ frontendDir,
141
+ onSkip: () => s.stop('Dependency installation skipped'),
142
+ packageManager,
143
+ });
145
144
  }
146
- catch (error) {
147
- ux.warn('Failed to install dependencies');
148
- throw error;
145
+ if (dependenciesInstalled) {
146
+ s.stop('Dependencies installed!');
149
147
  }
150
- s.stop('Dependencies installed!');
151
148
  }
152
149
  // Initialize Git repo
153
150
  if (flags.gitInit) {
@@ -165,7 +162,7 @@ export async function init({ dir, flags }) {
165
162
  : `- Complete the onboarding form at ${pinkText(directusInfo.url || 'http://localhost:8055')} to create your admin account. \n`;
166
163
  const frontendText = flags.frontend ? `- To start the frontend, run ${pinkText(`cd ${flags.frontend}`)} and then ${pinkText(`${packageManager?.name} run dev`)}. \n` : '';
167
164
  const projectText = `- Navigate to your project directory using ${pinkText(`cd ${relativeDir}`)}. \n`;
168
- const readmeText = '- Review the \`./README.md\` file for more information and next steps.';
165
+ const readmeText = '- Review the `./README.md` file for more information and next steps.';
169
166
  const nextSteps = `${directusText}${directusLoginText}${projectText}${frontendText}${readmeText}`;
170
167
  note(nextSteps, 'Next Steps');
171
168
  clackLog.warn(BSL_LICENSE_HEADLINE);
@@ -203,3 +200,22 @@ async function initGit(targetDir) {
203
200
  });
204
201
  }
205
202
  }
203
+ export async function installFrontendDependencies({ frontendDir, install = installDependencies, onSkip, packageManager, warn = ux.warn, }) {
204
+ try {
205
+ await install({
206
+ cwd: frontendDir,
207
+ packageManager: packageManager ?? undefined,
208
+ silent: true,
209
+ });
210
+ return true;
211
+ }
212
+ catch {
213
+ onSkip?.();
214
+ warn('Failed to install dependencies');
215
+ if (packageManager?.name === 'pnpm') {
216
+ warn('This starter uses pnpm. From the frontend directory, try running: corepack enable && pnpm install');
217
+ }
218
+ warn('You can install dependencies manually and continue using the generated project.');
219
+ return false;
220
+ }
221
+ }
@@ -45,7 +45,6 @@ export default async function apply(dir, flags) {
45
45
  await loadCollections(source, effectivePlan);
46
46
  await loadRelations(source, effectivePlan);
47
47
  await finalizeCollections(source, effectivePlan);
48
- await finalizeFields(source, effectivePlan);
49
48
  }
50
49
  if (components.permissions || components.users) {
51
50
  await loadRoles(source);
@@ -63,6 +62,10 @@ export default async function apply(dir, flags) {
63
62
  if (components.content) {
64
63
  await loadData(source, effectivePlan);
65
64
  }
65
+ if (components.schema) {
66
+ // Finalize fields after data loading because skeleton records rely on relaxed constraints.
67
+ await finalizeFields(source, effectivePlan);
68
+ }
66
69
  if (components.dashboards) {
67
70
  await loadDashboards(source);
68
71
  }
@@ -1,2 +1,3 @@
1
1
  import { type TemplatePlan } from '../template-plan/index.js';
2
2
  export default function loadData(dir: string, plan?: TemplatePlan): Promise<void>;
3
+ export declare function getUserCollections(dir: string, plan?: TemplatePlan): any[];
@@ -26,10 +26,11 @@ function getContentCollections(dir) {
26
26
  .filter((file) => file.endsWith('.json'))
27
27
  .map((file) => path.basename(file, '.json')));
28
28
  }
29
- function getUserCollections(dir, plan) {
29
+ export function getUserCollections(dir, plan) {
30
30
  const contentCollections = getContentCollections(dir);
31
31
  const collections = readFile('collections', dir);
32
- const relations = plan?.partial ? readFile('relations', dir) : [];
32
+ const relationsPath = path.join(dir, 'relations.json');
33
+ const relations = plan?.partial && fs.existsSync(relationsPath) ? readFile('relations', dir) : [];
33
34
  const brokenJunctions = getBrokenJunctionCollections(relations, plan);
34
35
  if (brokenJunctions.size > 0) {
35
36
  ux.warn(`Skipping junction collections with excluded FK targets: ${[...brokenJunctions].join(', ')}`);
@@ -21,13 +21,16 @@ export function applyMetadataToPlan(plan, metadata) {
21
21
  for (const component of componentNames) {
22
22
  components[component] = components[component] && metadata.components[component];
23
23
  }
24
- const partial = metadata.partial || componentNames.some((component) => components[component] !== plan.components[component]);
24
+ const partial = plan.partial ||
25
+ metadata.partial ||
26
+ componentNames.some((component) => components[component] !== plan.components[component]);
25
27
  return {
26
28
  ...plan,
27
29
  collections: intersectCollections('collections', plan.collections, metadata.collections),
28
30
  components,
29
31
  excludeCollections: mergeExcludedCollections(plan.excludeCollections, metadata.excludedCollections),
30
32
  partial,
33
+ relationStrategy: metadata.relationStrategy ?? plan.relationStrategy,
31
34
  schemaCollections: intersectCollections('schema collections', plan.schemaCollections, metadata.schemaCollections),
32
35
  };
33
36
  }
@@ -22,16 +22,19 @@ export function readTemplateMetadata(dir) {
22
22
  const filePath = getTemplateMetadataPath(dir);
23
23
  if (!fs.existsSync(filePath))
24
24
  return undefined;
25
+ let metadata;
25
26
  try {
26
- const metadata = JSON.parse(fs.readFileSync(filePath, 'utf8'));
27
- if (metadata.version !== 2) {
28
- catchError(new Error(`Unsupported template metadata version: ${metadata.version}`), { fatal: true });
29
- }
30
- return metadata;
27
+ metadata = JSON.parse(fs.readFileSync(filePath, 'utf8'));
31
28
  }
32
29
  catch (error) {
33
30
  catchError(error, { fatal: true });
31
+ return undefined;
32
+ }
33
+ if (metadata.version !== 2) {
34
+ catchError(new Error(`Unsupported template metadata version: ${metadata.version}`), { fatal: true });
35
+ return undefined;
34
36
  }
37
+ return metadata;
35
38
  }
36
39
  export async function writeTemplateMetadata(dir, plan, warnings = []) {
37
40
  const filePath = getTemplateMetadataPath(dir);
@@ -6,4 +6,5 @@ export declare function getCommunityTemplates(): Promise<Template[]>;
6
6
  export declare function getLocalTemplate(localTemplateDir: string): Promise<Template>;
7
7
  export declare function getInteractiveLocalTemplate(localTemplateDir: string): Promise<Template[]>;
8
8
  export declare function getGithubTemplate(ghTemplateUrl: string): Promise<Template>;
9
+ export declare function getInteractiveGithubTemplate(ghTemplateUrl: string): Promise<Template[]>;
9
10
  export {};
@@ -3,9 +3,10 @@ import fs from 'node:fs';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import path, { dirname } from 'pathe';
5
5
  import { COMMUNITY_TEMPLATE_REPO } from '../constants.js';
6
+ import { logger } from './logger.js';
6
7
  import resolvePathAndCheckExistence from './path.js';
7
8
  import { readAllTemplates, readTemplate } from './read-templates.js';
8
- import { transformGitHubUrl } from './transform-github-url.js';
9
+ import { parseGitHubUrl, transformGitHubUrl } from './transform-github-url.js';
9
10
  // Create __dirname equivalent for ESM
10
11
  const __filename = fileURLToPath(import.meta.url);
11
12
  const __dirname = dirname(__filename);
@@ -71,25 +72,74 @@ async function findNestedTemplates(dir, depth) {
71
72
  }
72
73
  return templates;
73
74
  }
75
+ async function downloadGithubTemplate(ghTemplateUrl) {
76
+ const ghString = transformGitHubUrl(ghTemplateUrl);
77
+ const downloadDir = resolvePathAndCheckExistence(path.join(__dirname, '..', 'downloads', 'github'), false);
78
+ if (!downloadDir) {
79
+ throw new Error(`Invalid download directory: ${path.join(__dirname, '..', 'downloads', 'github')}`);
80
+ }
81
+ const { dir } = await downloadTemplate(ghString, {
82
+ dir: downloadDir,
83
+ force: true,
84
+ forceClean: true,
85
+ });
86
+ const resolvedDir = resolvePathAndCheckExistence(dir);
87
+ if (!resolvedDir) {
88
+ throw new Error(`Downloaded template directory does not exist: ${dir}`);
89
+ }
90
+ return resolvedDir;
91
+ }
92
+ function buildSubpathUrl(ghTemplateUrl, templatePath) {
93
+ const { owner, ref, repo } = parseGitHubUrl(ghTemplateUrl);
94
+ const normalizedPath = templatePath.split(path.sep).join('/');
95
+ return `https://github.com/${owner}/${repo}/tree/${ref || 'HEAD'}/${normalizedPath}`;
96
+ }
97
+ function getErrorMessage(error) {
98
+ return error instanceof Error ? error.message : String(error);
99
+ }
74
100
  export async function getGithubTemplate(ghTemplateUrl) {
75
101
  try {
76
- const ghString = await transformGitHubUrl(ghTemplateUrl);
77
- const downloadDir = resolvePathAndCheckExistence(path.join(__dirname, '..', 'downloads', 'github'), false);
78
- if (!downloadDir) {
79
- throw new Error(`Invalid download directory: ${path.join(__dirname, '..', 'downloads', 'github')}`);
102
+ const resolvedDir = await downloadGithubTemplate(ghTemplateUrl);
103
+ const template = await readTemplate(resolvedDir);
104
+ if (template) {
105
+ return template;
80
106
  }
81
- const { dir } = await downloadTemplate(ghString, {
82
- dir: downloadDir,
83
- force: true,
84
- forceClean: true,
85
- });
86
- const resolvedDir = resolvePathAndCheckExistence(dir);
87
- if (!resolvedDir) {
88
- throw new Error(`Downloaded template directory does not exist: ${dir}`);
107
+ const nested = await findNestedTemplates(resolvedDir, 3);
108
+ if (nested.length === 1) {
109
+ const subpath = path.relative(resolvedDir, nested[0].directoryPath);
110
+ const pinnedUrl = buildSubpathUrl(ghTemplateUrl, subpath);
111
+ logger.log('warn', `Auto-selected nested template "${nested[0].templateName}" at ${subpath}. Pin --templateLocation="${pinnedUrl}" to avoid ambiguity if more templates are added.`);
112
+ return nested[0];
113
+ }
114
+ if (nested.length > 1) {
115
+ const list = nested
116
+ .map(t => {
117
+ const subpath = path.relative(resolvedDir, t.directoryPath);
118
+ return ` --templateLocation="${buildSubpathUrl(ghTemplateUrl, subpath)}" # ${t.templateName}`;
119
+ })
120
+ .join('\n');
121
+ throw new Error(`Found multiple Directus templates in ${ghTemplateUrl}. Re-run with one of:\n${list}`);
122
+ }
123
+ throw new Error(`No Directus template found at ${ghTemplateUrl}. A Directus template needs a package.json with a "templateName" field.`);
124
+ }
125
+ catch (error) {
126
+ throw new Error(`Failed to download GitHub template: ${getErrorMessage(error)}`, { cause: error });
127
+ }
128
+ }
129
+ export async function getInteractiveGithubTemplate(ghTemplateUrl) {
130
+ try {
131
+ const resolvedDir = await downloadGithubTemplate(ghTemplateUrl);
132
+ const template = await readTemplate(resolvedDir);
133
+ if (template) {
134
+ return [template];
135
+ }
136
+ const nested = await findNestedTemplates(resolvedDir, 3);
137
+ if (nested.length === 0) {
138
+ throw new Error(`No Directus template found at ${ghTemplateUrl}. A Directus template needs a package.json with a "templateName" field.`);
89
139
  }
90
- return readTemplate(resolvedDir);
140
+ return nested;
91
141
  }
92
142
  catch (error) {
93
- throw new Error(`Failed to download GitHub template: ${error}`);
143
+ throw new Error(`Failed to download GitHub template: ${getErrorMessage(error)}`, { cause: error });
94
144
  }
95
145
  }
@@ -1 +1,8 @@
1
+ export interface ParsedGitHubUrl {
2
+ owner: string;
3
+ ref?: string;
4
+ repo: string;
5
+ subpath?: string;
6
+ }
7
+ export declare function parseGitHubUrl(url: string): ParsedGitHubUrl;
1
8
  export declare function transformGitHubUrl(url: string): string;
@@ -1,11 +1,31 @@
1
+ export function parseGitHubUrl(url) {
2
+ const cleaned = url.trim().replace(/\/+$/, '');
3
+ const urlToParse = /^https?:\/\//i.test(cleaned) ? cleaned : `https://${cleaned}`;
4
+ let parsed;
5
+ try {
6
+ parsed = new URL(urlToParse);
7
+ }
8
+ catch {
9
+ throw new Error(`Invalid GitHub URL: ${url}`);
10
+ }
11
+ if (!['github.com', 'www.github.com'].includes(parsed.hostname.toLowerCase())) {
12
+ throw new Error(`Invalid GitHub URL: ${url}`);
13
+ }
14
+ const pathParts = parsed.pathname.split('/').filter(Boolean);
15
+ const [owner, rawRepo, tree, ref, ...subpathParts] = pathParts;
16
+ const repo = rawRepo?.replace(/\.git$/, '');
17
+ if (!owner || !repo || pathParts.length > 2 && tree !== 'tree') {
18
+ throw new Error(`Invalid GitHub URL: ${url}`);
19
+ }
20
+ if (tree === 'tree' && !ref) {
21
+ throw new Error(`Invalid GitHub URL: ${url}`);
22
+ }
23
+ const subpath = subpathParts.length > 0 ? subpathParts.join('/') : undefined;
24
+ return { owner, ref, repo, subpath };
25
+ }
1
26
  export function transformGitHubUrl(url) {
2
- // Regular expression to capture the repository name and any subsequent path after the 'tree'
3
- const regex = /github\.com\/([^/]+\/[^/]+)(?:\/tree\/[^/]+\/(.*))?$/;
4
- const match = url.match(regex);
5
- if (match) {
6
- const repo = match[1];
7
- const subpath = match[2] ? match[2] : '';
8
- return `github:${repo}/${subpath}`;
9
- }
10
- return 'Invalid URL';
27
+ const { owner, ref, repo, subpath } = parseGitHubUrl(url);
28
+ const pathPart = subpath ? `/${subpath}` : '';
29
+ const refPart = ref ? `#${ref}` : '';
30
+ return `github:${owner}/${repo}${pathPart}${refPart}`;
11
31
  }
@@ -558,5 +558,5 @@
558
558
  ]
559
559
  }
560
560
  },
561
- "version": "0.8.0-partials.0"
561
+ "version": "0.8.0"
562
562
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "directus-template-cli",
3
- "version": "0.8.0-partials.0",
3
+ "version": "0.8.0",
4
4
  "description": "CLI Utility for applying templates to a Directus instance.",
5
5
  "author": "bryantgillespie @bryantgillespie",
6
6
  "type": "module",
@@ -19,44 +19,44 @@
19
19
  ],
20
20
  "dependencies": {
21
21
  "@clack/prompts": "^0.10.0",
22
- "@directus/sdk": "20.1.0",
23
- "@inquirer/prompts": "^7.3.3",
24
- "@oclif/core": "^4.2.9",
25
- "@oclif/plugin-help": "^6.2.26",
26
- "@oclif/plugin-plugins": "^5.4.34",
27
- "@octokit/rest": "^21.1.1",
28
- "@sindresorhus/slugify": "^2.2.1",
22
+ "@directus/sdk": "21.2.2",
23
+ "@inquirer/prompts": "^8.4.1",
24
+ "@oclif/core": "^4.10.5",
25
+ "@oclif/plugin-help": "^6.2.44",
26
+ "@oclif/plugin-plugins": "^5.4.61",
27
+ "@octokit/rest": "^22.0.1",
28
+ "@sindresorhus/slugify": "^3.0.0",
29
29
  "bottleneck": "^2.19.5",
30
- "chalk": "5.4.1",
30
+ "chalk": "5.6.2",
31
31
  "cli-progress": "^3.12.0",
32
- "defu": "^6.1.4",
33
- "dotenv": "^16.4.7",
34
- "execa": "9.5.2",
35
- "giget": "^2.0.0",
36
- "glob": "^11.0.1",
37
- "log-update": "^6.1.0",
38
- "nypm": "^0.6.0",
32
+ "defu": "^6.1.7",
33
+ "dotenv": "^17.4.2",
34
+ "execa": "9.6.1",
35
+ "giget": "^3.2.0",
36
+ "glob": "^13.0.6",
37
+ "log-update": "^8.0.0",
38
+ "nypm": "^0.6.5",
39
39
  "pathe": "^2.0.3",
40
- "posthog-node": "^4.10.1"
40
+ "posthog-node": "^5.29.2"
41
41
  },
42
42
  "devDependencies": {
43
- "@directus/types": "^13.0.0",
44
- "@eslint/compat": "^1",
43
+ "@directus/types": "^15.0.2",
44
+ "@eslint/compat": "^2.0.5",
45
45
  "@oclif/prettier-config": "^0.2.1",
46
- "@oclif/test": "^4",
47
- "@types/chai": "^5.2.0",
46
+ "@oclif/test": "^4.1.18",
47
+ "@types/chai": "^5.2.3",
48
48
  "@types/mocha": "^10",
49
- "@types/node": "^18",
50
- "chai": "^5.2.0",
51
- "eslint": "^9.39.2",
52
- "eslint-config-oclif": "^6.0.130",
49
+ "@types/node": "^25.6.0",
50
+ "chai": "^6.2.2",
51
+ "eslint": "^10.2.1",
52
+ "eslint-config-oclif": "^6.0.157",
53
53
  "eslint-config-prettier": "^10",
54
- "mocha": "^10",
55
- "oclif": "^4",
56
- "prettier": "^3.7.4",
57
- "shx": "^0.3.3",
54
+ "mocha": "^11.7.5",
55
+ "oclif": "^4.23.0",
56
+ "prettier": "^3.8.3",
57
+ "shx": "^0.4.0",
58
58
  "ts-node": "^10",
59
- "typescript": "^5.8.2"
59
+ "typescript": "^6.0.3"
60
60
  },
61
61
  "oclif": {
62
62
  "bin": "directus-template-cli",