@agiflowai/aicode-utils 1.0.7 → 1.0.9

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.
package/dist/index.mjs CHANGED
@@ -1,11 +1,12 @@
1
1
  import { createRequire } from "node:module";
2
- import * as path$1 from "node:path";
3
- import path from "node:path";
2
+ import * as path from "node:path";
3
+ import nodePath from "node:path";
4
4
  import * as fs from "node:fs/promises";
5
5
  import { accessSync, mkdirSync, readFileSync, readFileSync as readFileSync$1, statSync, writeFileSync } from "node:fs";
6
6
  import * as os from "node:os";
7
7
  import pino from "pino";
8
8
  import * as yaml from "js-yaml";
9
+ import { execa } from "execa";
9
10
  import chalk from "chalk";
10
11
 
11
12
  //#region rolldown:runtime
@@ -65,12 +66,6 @@ async function ensureDir(dirPath) {
65
66
  await fs.mkdir(dirPath, { recursive: true });
66
67
  }
67
68
  /**
68
- * Ensure a directory exists (sync), creating it recursively if needed
69
- */
70
- function ensureDirSync(dirPath) {
71
- mkdirSync(dirPath, { recursive: true });
72
- }
73
- /**
74
69
  * Remove a file or directory recursively
75
70
  */
76
71
  async function remove(filePath) {
@@ -89,7 +84,7 @@ async function copy(src, dest) {
89
84
  * Move a file or directory
90
85
  */
91
86
  async function move(src, dest) {
92
- await ensureDir(path.dirname(dest));
87
+ await ensureDir(nodePath.dirname(dest));
93
88
  await fs.rename(src, dest);
94
89
  }
95
90
  /**
@@ -113,19 +108,6 @@ async function writeJson(filePath, data, options) {
113
108
  const content = JSON.stringify(data, null, options?.spaces ?? 2);
114
109
  await fs.writeFile(filePath, `${content}\n`, "utf-8");
115
110
  }
116
- /**
117
- * Write an object as JSON to a file (sync)
118
- */
119
- function writeJsonSync(filePath, data, options) {
120
- writeFileSync(filePath, `${JSON.stringify(data, null, options?.spaces ?? 2)}\n`, "utf-8");
121
- }
122
- /**
123
- * Output file - writes content ensuring directory exists
124
- */
125
- async function outputFile(filePath, content) {
126
- await ensureDir(path.dirname(filePath));
127
- await fs.writeFile(filePath, content, "utf-8");
128
- }
129
111
  const readFile = fs.readFile;
130
112
  const writeFile = fs.writeFile;
131
113
  const readdir = fs.readdir;
@@ -138,12 +120,12 @@ const cp = fs.cp;
138
120
 
139
121
  //#endregion
140
122
  //#region src/utils/logger.ts
141
- const logsDir = path$1.join(os.tmpdir(), "scaffold-mcp-logs");
123
+ const logsDir = path.join(os.tmpdir(), "scaffold-mcp-logs");
142
124
  const logger = pino({
143
125
  level: process.env.LOG_LEVEL || "debug",
144
126
  timestamp: pino.stdTimeFunctions.isoTime
145
127
  }, pino.destination({
146
- dest: path$1.join(logsDir, "scaffold-mcp.log"),
128
+ dest: path.join(logsDir, "scaffold-mcp.log"),
147
129
  mkdir: true,
148
130
  sync: true
149
131
  }));
@@ -195,36 +177,35 @@ var TemplatesManagerService = class TemplatesManagerService {
195
177
  * 4. Verify the templates directory exists
196
178
  *
197
179
  * @param startPath - The path to start searching from (defaults to process.cwd())
198
- * @returns The absolute path to the templates directory
199
- * @throws Error if templates directory is not found
180
+ * @returns The absolute path to the templates directory, or null if not found
200
181
  */
201
182
  static async findTemplatesPath(startPath = process.cwd()) {
202
183
  const workspaceRoot = await TemplatesManagerService.findWorkspaceRoot(startPath);
203
- const toolkitConfigPath = path.join(workspaceRoot, TemplatesManagerService.TOOLKIT_CONFIG_FILE);
184
+ const toolkitConfigPath = nodePath.join(workspaceRoot, TemplatesManagerService.TOOLKIT_CONFIG_FILE);
204
185
  if (await pathExists(toolkitConfigPath)) {
205
186
  const yaml$1 = await import("js-yaml");
206
187
  const content = await fs.readFile(toolkitConfigPath, "utf-8");
207
188
  const config = yaml$1.load(content);
208
189
  if (config?.templatesPath) {
209
- const templatesPath$1 = path.isAbsolute(config.templatesPath) ? config.templatesPath : path.join(workspaceRoot, config.templatesPath);
190
+ const templatesPath$1 = nodePath.isAbsolute(config.templatesPath) ? config.templatesPath : nodePath.join(workspaceRoot, config.templatesPath);
210
191
  if (await pathExists(templatesPath$1)) return templatesPath$1;
211
- else throw new Error(`Templates path specified in toolkit.yaml does not exist: ${templatesPath$1}`);
192
+ else return null;
212
193
  }
213
194
  }
214
- const templatesPath = path.join(workspaceRoot, TemplatesManagerService.TEMPLATES_FOLDER);
195
+ const templatesPath = nodePath.join(workspaceRoot, TemplatesManagerService.TEMPLATES_FOLDER);
215
196
  if (await pathExists(templatesPath)) return templatesPath;
216
- throw new Error(`Templates folder not found at ${templatesPath}.\nEither create a 'templates' folder or specify templatesPath in toolkit.yaml`);
197
+ return null;
217
198
  }
218
199
  /**
219
200
  * Find the workspace root by searching upwards for .git folder
220
201
  */
221
202
  static async findWorkspaceRoot(startPath) {
222
- let currentPath = path.resolve(startPath);
223
- const rootPath = path.parse(currentPath).root;
203
+ let currentPath = nodePath.resolve(startPath);
204
+ const rootPath = nodePath.parse(currentPath).root;
224
205
  while (true) {
225
- if (await pathExists(path.join(currentPath, ".git"))) return currentPath;
206
+ if (await pathExists(nodePath.join(currentPath, ".git"))) return currentPath;
226
207
  if (currentPath === rootPath) return process.cwd();
227
- currentPath = path.dirname(currentPath);
208
+ currentPath = nodePath.dirname(currentPath);
228
209
  }
229
210
  }
230
211
  /**
@@ -232,36 +213,35 @@ var TemplatesManagerService = class TemplatesManagerService {
232
213
  * Use this when you need immediate access and are sure templates exist.
233
214
  *
234
215
  * @param startPath - The path to start searching from (defaults to process.cwd())
235
- * @returns The absolute path to the templates directory
236
- * @throws Error if templates directory is not found
216
+ * @returns The absolute path to the templates directory, or null if not found
237
217
  */
238
218
  static findTemplatesPathSync(startPath = process.cwd()) {
239
219
  const workspaceRoot = TemplatesManagerService.findWorkspaceRootSync(startPath);
240
- const toolkitConfigPath = path.join(workspaceRoot, TemplatesManagerService.TOOLKIT_CONFIG_FILE);
220
+ const toolkitConfigPath = nodePath.join(workspaceRoot, TemplatesManagerService.TOOLKIT_CONFIG_FILE);
241
221
  if (pathExistsSync(toolkitConfigPath)) {
242
222
  const yaml$1 = __require("js-yaml");
243
223
  const content = readFileSync$1(toolkitConfigPath, "utf-8");
244
224
  const config = yaml$1.load(content);
245
225
  if (config?.templatesPath) {
246
- const templatesPath$1 = path.isAbsolute(config.templatesPath) ? config.templatesPath : path.join(workspaceRoot, config.templatesPath);
226
+ const templatesPath$1 = nodePath.isAbsolute(config.templatesPath) ? config.templatesPath : nodePath.join(workspaceRoot, config.templatesPath);
247
227
  if (pathExistsSync(templatesPath$1)) return templatesPath$1;
248
- else throw new Error(`Templates path specified in toolkit.yaml does not exist: ${templatesPath$1}`);
228
+ else return null;
249
229
  }
250
230
  }
251
- const templatesPath = path.join(workspaceRoot, TemplatesManagerService.TEMPLATES_FOLDER);
231
+ const templatesPath = nodePath.join(workspaceRoot, TemplatesManagerService.TEMPLATES_FOLDER);
252
232
  if (pathExistsSync(templatesPath)) return templatesPath;
253
- throw new Error(`Templates folder not found at ${templatesPath}.\nEither create a 'templates' folder or specify templatesPath in toolkit.yaml`);
233
+ return null;
254
234
  }
255
235
  /**
256
236
  * Find the workspace root synchronously by searching upwards for .git folder
257
237
  */
258
238
  static findWorkspaceRootSync(startPath) {
259
- let currentPath = path.resolve(startPath);
260
- const rootPath = path.parse(currentPath).root;
239
+ let currentPath = nodePath.resolve(startPath);
240
+ const rootPath = nodePath.parse(currentPath).root;
261
241
  while (true) {
262
- if (pathExistsSync(path.join(currentPath, ".git"))) return currentPath;
242
+ if (pathExistsSync(nodePath.join(currentPath, ".git"))) return currentPath;
263
243
  if (currentPath === rootPath) return process.cwd();
264
- currentPath = path.dirname(currentPath);
244
+ currentPath = nodePath.dirname(currentPath);
265
245
  }
266
246
  }
267
247
  /**
@@ -294,7 +274,7 @@ var TemplatesManagerService = class TemplatesManagerService {
294
274
  */
295
275
  static async readToolkitConfig(startPath = process.cwd()) {
296
276
  const workspaceRoot = await TemplatesManagerService.findWorkspaceRoot(startPath);
297
- const toolkitConfigPath = path.join(workspaceRoot, TemplatesManagerService.TOOLKIT_CONFIG_FILE);
277
+ const toolkitConfigPath = nodePath.join(workspaceRoot, TemplatesManagerService.TOOLKIT_CONFIG_FILE);
298
278
  if (!await pathExists(toolkitConfigPath)) return null;
299
279
  const yaml$1 = await import("js-yaml");
300
280
  const content = await fs.readFile(toolkitConfigPath, "utf-8");
@@ -308,7 +288,7 @@ var TemplatesManagerService = class TemplatesManagerService {
308
288
  */
309
289
  static readToolkitConfigSync(startPath = process.cwd()) {
310
290
  const workspaceRoot = TemplatesManagerService.findWorkspaceRootSync(startPath);
311
- const toolkitConfigPath = path.join(workspaceRoot, TemplatesManagerService.TOOLKIT_CONFIG_FILE);
291
+ const toolkitConfigPath = nodePath.join(workspaceRoot, TemplatesManagerService.TOOLKIT_CONFIG_FILE);
312
292
  if (!pathExistsSync(toolkitConfigPath)) return null;
313
293
  const yaml$1 = __require("js-yaml");
314
294
  const content = readFileSync$1(toolkitConfigPath, "utf-8");
@@ -322,7 +302,7 @@ var TemplatesManagerService = class TemplatesManagerService {
322
302
  */
323
303
  static async writeToolkitConfig(config, startPath = process.cwd()) {
324
304
  const workspaceRoot = await TemplatesManagerService.findWorkspaceRoot(startPath);
325
- const toolkitConfigPath = path.join(workspaceRoot, TemplatesManagerService.TOOLKIT_CONFIG_FILE);
305
+ const toolkitConfigPath = nodePath.join(workspaceRoot, TemplatesManagerService.TOOLKIT_CONFIG_FILE);
326
306
  const content = (await import("js-yaml")).dump(config, { indent: 2 });
327
307
  await fs.writeFile(toolkitConfigPath, content, "utf-8");
328
308
  }
@@ -393,13 +373,13 @@ var ProjectConfigResolver = class ProjectConfigResolver {
393
373
  */
394
374
  static async resolveProjectConfig(projectPath, explicitTemplate) {
395
375
  try {
396
- const absolutePath = path.resolve(projectPath);
376
+ const absolutePath = nodePath.resolve(projectPath);
397
377
  if (explicitTemplate) return {
398
378
  type: ProjectType.MONOLITH,
399
379
  sourceTemplate: explicitTemplate,
400
380
  configSource: ConfigSource.TOOLKIT_YAML
401
381
  };
402
- const projectJsonPath = path.join(absolutePath, "project.json");
382
+ const projectJsonPath = nodePath.join(absolutePath, "project.json");
403
383
  if (await pathExists(projectJsonPath)) {
404
384
  const projectJson = await readJson(projectJsonPath);
405
385
  if (projectJson.sourceTemplate && typeof projectJson.sourceTemplate === "string" && projectJson.sourceTemplate.trim()) return {
@@ -490,12 +470,12 @@ Run 'scaffold-mcp scaffold list --help' for more info.`;
490
470
  * @param sourceTemplate - The template identifier
491
471
  */
492
472
  static async createProjectJson(projectPath, projectName, sourceTemplate) {
493
- const projectJsonPath = path.join(projectPath, "project.json");
473
+ const projectJsonPath = nodePath.join(projectPath, "project.json");
494
474
  try {
495
475
  let projectJson;
496
476
  if (await pathExists(projectJsonPath)) projectJson = await readJson(projectJsonPath);
497
477
  else {
498
- const relativePath = path.relative(projectPath, process.cwd());
478
+ const relativePath = nodePath.relative(projectPath, process.cwd());
499
479
  projectJson = {
500
480
  name: projectName,
501
481
  $schema: relativePath ? `${relativePath}/node_modules/nx/schemas/project-schema.json` : "node_modules/nx/schemas/project-schema.json",
@@ -547,15 +527,15 @@ var ProjectFinderService = class {
547
527
  * @returns Project configuration or null if not found
548
528
  */
549
529
  async findProjectForFile(filePath) {
550
- const normalizedPath = path.isAbsolute(filePath) ? filePath : path.join(this.workspaceRoot, filePath);
551
- let currentDir = path.dirname(normalizedPath);
530
+ const normalizedPath = nodePath.isAbsolute(filePath) ? filePath : nodePath.join(this.workspaceRoot, filePath);
531
+ let currentDir = nodePath.dirname(normalizedPath);
552
532
  while (currentDir !== "/" && currentDir.startsWith(this.workspaceRoot)) {
553
- const projectJsonPath = path.join(currentDir, "project.json");
533
+ const projectJsonPath = nodePath.join(currentDir, "project.json");
554
534
  try {
555
535
  const project = await this.loadProjectConfig(projectJsonPath);
556
536
  if (project) return project;
557
537
  } catch {}
558
- currentDir = path.dirname(currentDir);
538
+ currentDir = nodePath.dirname(currentDir);
559
539
  }
560
540
  return null;
561
541
  }
@@ -566,15 +546,15 @@ var ProjectFinderService = class {
566
546
  * @returns Project configuration or null if not found
567
547
  */
568
548
  findProjectForFileSync(filePath) {
569
- const normalizedPath = path.isAbsolute(filePath) ? filePath : path.join(this.workspaceRoot, filePath);
570
- let currentDir = path.dirname(normalizedPath);
549
+ const normalizedPath = nodePath.isAbsolute(filePath) ? filePath : nodePath.join(this.workspaceRoot, filePath);
550
+ let currentDir = nodePath.dirname(normalizedPath);
571
551
  while (currentDir !== "/" && currentDir.startsWith(this.workspaceRoot)) {
572
- const projectJsonPath = path.join(currentDir, "project.json");
552
+ const projectJsonPath = nodePath.join(currentDir, "project.json");
573
553
  try {
574
554
  const project = this.loadProjectConfigSync(projectJsonPath);
575
555
  if (project) return project;
576
556
  } catch {}
577
- currentDir = path.dirname(currentDir);
557
+ currentDir = nodePath.dirname(currentDir);
578
558
  }
579
559
  return null;
580
560
  }
@@ -587,8 +567,8 @@ var ProjectFinderService = class {
587
567
  const content = await fs.readFile(projectJsonPath, "utf-8");
588
568
  const config = JSON.parse(content);
589
569
  const projectConfig = {
590
- name: config.name || path.basename(path.dirname(projectJsonPath)),
591
- root: path.dirname(projectJsonPath),
570
+ name: config.name || nodePath.basename(nodePath.dirname(projectJsonPath)),
571
+ root: nodePath.dirname(projectJsonPath),
592
572
  sourceTemplate: config.sourceTemplate,
593
573
  projectType: config.projectType
594
574
  };
@@ -607,8 +587,8 @@ var ProjectFinderService = class {
607
587
  const content = readFileSync$1(projectJsonPath, "utf-8");
608
588
  const config = JSON.parse(content);
609
589
  const projectConfig = {
610
- name: config.name || path.basename(path.dirname(projectJsonPath)),
611
- root: path.dirname(projectJsonPath),
590
+ name: config.name || nodePath.basename(nodePath.dirname(projectJsonPath)),
591
+ root: nodePath.dirname(projectJsonPath),
612
592
  sourceTemplate: config.sourceTemplate,
613
593
  projectType: config.projectType
614
594
  };
@@ -668,7 +648,7 @@ var ScaffoldProcessingService = class {
668
648
  * Now supports tracking existing files separately from created files
669
649
  */
670
650
  async copyAndProcess(sourcePath, targetPath, variables, createdFiles, existingFiles) {
671
- await this.fileSystem.ensureDir(path.dirname(targetPath));
651
+ await this.fileSystem.ensureDir(nodePath.dirname(targetPath));
672
652
  if (await this.fileSystem.pathExists(targetPath) && existingFiles) {
673
653
  await this.trackExistingFiles(targetPath, existingFiles);
674
654
  return;
@@ -690,7 +670,7 @@ var ScaffoldProcessingService = class {
690
670
  }
691
671
  for (const item of items) {
692
672
  if (!item) continue;
693
- const itemPath = path.join(dirPath, item);
673
+ const itemPath = nodePath.join(dirPath, item);
694
674
  try {
695
675
  const stat$1 = await this.fileSystem.stat(itemPath);
696
676
  if (stat$1.isDirectory()) await this.trackCreatedFilesRecursive(itemPath, createdFiles);
@@ -713,7 +693,7 @@ var ScaffoldProcessingService = class {
713
693
  }
714
694
  for (const item of items) {
715
695
  if (!item) continue;
716
- const itemPath = path.join(dirPath, item);
696
+ const itemPath = nodePath.join(dirPath, item);
717
697
  try {
718
698
  const stat$1 = await this.fileSystem.stat(itemPath);
719
699
  if (stat$1.isDirectory()) await this.trackExistingFilesRecursive(itemPath, existingFiles);
@@ -755,6 +735,258 @@ function generateStableId(length = 8) {
755
735
  return result;
756
736
  }
757
737
 
738
+ //#endregion
739
+ //#region src/utils/git.ts
740
+ /**
741
+ * Git Utilities
742
+ *
743
+ * DESIGN PATTERNS:
744
+ * - Safe command execution: Use execa with array arguments to prevent shell injection
745
+ * - Defense in depth: Use '--' separator to prevent option injection attacks
746
+ *
747
+ * CODING STANDARDS:
748
+ * - All git commands must use execGit helper with array arguments
749
+ * - Use '--' separator before user-provided arguments (URLs, branches, paths)
750
+ * - Validate inputs where appropriate
751
+ *
752
+ * AVOID:
753
+ * - Shell string interpolation
754
+ * - Passing unsanitized user input as command options
755
+ *
756
+ * NOTE: These utilities perform I/O operations (git commands, file system) by necessity.
757
+ * Pure utility functions like parseGitHubUrl are side-effect free.
758
+ */
759
+ /**
760
+ * Type guard to check if an error has the expected execa error shape
761
+ * @param error - The error to check
762
+ * @returns True if the error has message property (and optionally stderr)
763
+ * @example
764
+ * try {
765
+ * await execa('git', ['status']);
766
+ * } catch (error) {
767
+ * if (isExecaError(error)) {
768
+ * console.error(error.stderr || error.message);
769
+ * }
770
+ * }
771
+ */
772
+ function isExecaError(error) {
773
+ if (typeof error !== "object" || error === null) return false;
774
+ if (!("message" in error)) return false;
775
+ return typeof error.message === "string";
776
+ }
777
+ /**
778
+ * Execute a git command safely using execa to prevent command injection
779
+ * @param args - Array of git command arguments
780
+ * @param cwd - Optional working directory for the command
781
+ * @throws Error when git command fails
782
+ */
783
+ async function execGit(args, cwd) {
784
+ try {
785
+ await execa("git", args, { cwd });
786
+ } catch (error) {
787
+ if (isExecaError(error)) throw new Error(`Git command failed: ${error.stderr || error.message}`);
788
+ throw error;
789
+ }
790
+ }
791
+ /**
792
+ * Execute git init safely using execa to prevent command injection
793
+ * Uses '--' to prevent projectPath from being interpreted as an option
794
+ * @param projectPath - Path where to initialize the git repository
795
+ * @throws Error when git init fails
796
+ */
797
+ async function gitInit(projectPath) {
798
+ try {
799
+ await execa("git", [
800
+ "init",
801
+ "--",
802
+ projectPath
803
+ ]);
804
+ } catch (error) {
805
+ if (isExecaError(error)) throw new Error(`Git init failed: ${error.stderr || error.message}`);
806
+ throw error;
807
+ }
808
+ }
809
+ /**
810
+ * Find the workspace root by searching upwards for .git folder
811
+ * Returns null if no .git folder is found (indicating a new project setup is needed)
812
+ * @param startPath - The path to start searching from (default: current working directory)
813
+ * @returns The workspace root path or null if not in a git repository
814
+ * @example
815
+ * const root = await findWorkspaceRoot('/path/to/project/src');
816
+ * if (root) {
817
+ * console.log('Workspace root:', root);
818
+ * } else {
819
+ * console.log('No git repository found');
820
+ * }
821
+ */
822
+ async function findWorkspaceRoot(startPath = process.cwd()) {
823
+ let currentPath = nodePath.resolve(startPath);
824
+ const rootPath = nodePath.parse(currentPath).root;
825
+ while (true) {
826
+ if (await pathExists(nodePath.join(currentPath, ".git"))) return currentPath;
827
+ if (currentPath === rootPath) return null;
828
+ currentPath = nodePath.dirname(currentPath);
829
+ }
830
+ }
831
+ /**
832
+ * Parse GitHub URL to detect if it's a subdirectory
833
+ * Supports formats:
834
+ * - https://github.com/user/repo
835
+ * - https://github.com/user/repo/tree/branch/path/to/dir
836
+ * - https://github.com/user/repo/tree/main/path/to/dir
837
+ * @param url - The GitHub URL to parse
838
+ * @returns Parsed URL components including owner, repo, branch, and subdirectory
839
+ * @example
840
+ * const result = parseGitHubUrl('https://github.com/user/repo/tree/main/src');
841
+ * // result.owner === 'user'
842
+ * // result.repo === 'repo'
843
+ * // result.branch === 'main'
844
+ * // result.subdirectory === 'src'
845
+ * // result.isSubdirectory === true
846
+ */
847
+ function parseGitHubUrl(url) {
848
+ const treeMatch = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/tree\/([^/]+)\/(.+)$/);
849
+ const blobMatch = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/blob\/([^/]+)\/(.+)$/);
850
+ const rootMatch = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/);
851
+ if (treeMatch || blobMatch) {
852
+ const match = treeMatch || blobMatch;
853
+ return {
854
+ owner: match?.[1],
855
+ repo: match?.[2],
856
+ repoUrl: `https://github.com/${match?.[1]}/${match?.[2]}.git`,
857
+ branch: match?.[3],
858
+ subdirectory: match?.[4],
859
+ isSubdirectory: true
860
+ };
861
+ }
862
+ if (rootMatch) return {
863
+ owner: rootMatch[1],
864
+ repo: rootMatch[2],
865
+ repoUrl: `https://github.com/${rootMatch[1]}/${rootMatch[2]}.git`,
866
+ isSubdirectory: false
867
+ };
868
+ return {
869
+ repoUrl: url,
870
+ isSubdirectory: false
871
+ };
872
+ }
873
+ /**
874
+ * Clone a subdirectory from a git repository using sparse checkout
875
+ * Uses '--' to mark end of options - prevents malicious URLs like '--upload-pack=evil'
876
+ * from being interpreted as git options
877
+ * @param repoUrl - The git repository URL
878
+ * @param branch - The branch to clone from
879
+ * @param subdirectory - The subdirectory path within the repository
880
+ * @param targetFolder - The local folder to clone into
881
+ * @throws Error if subdirectory not found or target folder already exists
882
+ * @example
883
+ * await cloneSubdirectory(
884
+ * 'https://github.com/user/repo.git',
885
+ * 'main',
886
+ * 'packages/core',
887
+ * './my-project'
888
+ * );
889
+ */
890
+ async function cloneSubdirectory(repoUrl, branch, subdirectory, targetFolder) {
891
+ const tempFolder = `${targetFolder}.tmp`;
892
+ try {
893
+ await execGit([
894
+ "init",
895
+ "--",
896
+ tempFolder
897
+ ]);
898
+ await execGit([
899
+ "remote",
900
+ "add",
901
+ "origin",
902
+ "--",
903
+ repoUrl
904
+ ], tempFolder);
905
+ await execGit([
906
+ "config",
907
+ "core.sparseCheckout",
908
+ "true"
909
+ ], tempFolder);
910
+ await writeFile(nodePath.join(tempFolder, ".git", "info", "sparse-checkout"), `${subdirectory}\n`);
911
+ await execGit([
912
+ "pull",
913
+ "--depth=1",
914
+ "origin",
915
+ "--",
916
+ branch
917
+ ], tempFolder);
918
+ const sourceDir = nodePath.join(tempFolder, subdirectory);
919
+ if (!await pathExists(sourceDir)) throw new Error(`Subdirectory '${subdirectory}' not found in repository at branch '${branch}'`);
920
+ if (await pathExists(targetFolder)) throw new Error(`Target folder already exists: ${targetFolder}`);
921
+ await move(sourceDir, targetFolder);
922
+ await remove(tempFolder);
923
+ } catch (error) {
924
+ if (await pathExists(tempFolder)) await remove(tempFolder);
925
+ throw error;
926
+ }
927
+ }
928
+ /**
929
+ * Clone entire repository
930
+ * Uses '--' to mark end of options - prevents malicious URLs like '--upload-pack=evil'
931
+ * from being interpreted as git options
932
+ * @param repoUrl - The git repository URL to clone
933
+ * @param targetFolder - The local folder path to clone into
934
+ * @throws Error if git clone fails
935
+ * @example
936
+ * await cloneRepository('https://github.com/user/repo.git', './my-project');
937
+ */
938
+ async function cloneRepository(repoUrl, targetFolder) {
939
+ await execGit([
940
+ "clone",
941
+ "--",
942
+ repoUrl,
943
+ targetFolder
944
+ ]);
945
+ const gitFolder = nodePath.join(targetFolder, ".git");
946
+ if (await pathExists(gitFolder)) await remove(gitFolder);
947
+ }
948
+ /**
949
+ * Type guard to validate GitHub API content item structure
950
+ * @param item - The item to validate
951
+ * @returns True if the item has required GitHubContentItem properties
952
+ */
953
+ function isGitHubContentItem(item) {
954
+ if (typeof item !== "object" || item === null) return false;
955
+ const obj = item;
956
+ return typeof obj.name === "string" && typeof obj.type === "string" && typeof obj.path === "string";
957
+ }
958
+ /**
959
+ * Fetch directory listing from GitHub API
960
+ * @param owner - The GitHub repository owner/organization
961
+ * @param repo - The repository name
962
+ * @param dirPath - The directory path within the repository
963
+ * @param branch - The branch to fetch from (default: 'main')
964
+ * @returns Array of directory entries with name, type, and path
965
+ * @throws Error if the API request fails or returns non-directory content
966
+ * @remarks
967
+ * - Requires network access to GitHub API
968
+ * - Subject to GitHub API rate limits (60 requests/hour unauthenticated)
969
+ * - Only works with public repositories without authentication
970
+ * @example
971
+ * const contents = await fetchGitHubDirectoryContents('facebook', 'react', 'packages', 'main');
972
+ * // contents: [{ name: 'react', type: 'dir', path: 'packages/react' }, ...]
973
+ */
974
+ async function fetchGitHubDirectoryContents(owner, repo, dirPath, branch = "main") {
975
+ const url = `https://api.github.com/repos/${owner}/${repo}/contents/${dirPath}?ref=${branch}`;
976
+ const response = await fetch(url, { headers: {
977
+ Accept: "application/vnd.github.v3+json",
978
+ "User-Agent": "scaffold-mcp"
979
+ } });
980
+ if (!response.ok) throw new Error(`Failed to fetch directory contents: ${response.statusText}`);
981
+ const data = await response.json();
982
+ if (!Array.isArray(data)) throw new Error("Expected directory but got file");
983
+ return data.filter(isGitHubContentItem).map((item) => ({
984
+ name: item.name,
985
+ type: item.type,
986
+ path: item.path
987
+ }));
988
+ }
989
+
758
990
  //#endregion
759
991
  //#region src/utils/print.ts
760
992
  /**
@@ -927,7 +1159,7 @@ const MONOREPO_INDICATOR_FILES = [
927
1159
  */
928
1160
  async function detectProjectType(workspaceRoot) {
929
1161
  const indicators = [];
930
- const toolkitYamlPath = path.join(workspaceRoot, "toolkit.yaml");
1162
+ const toolkitYamlPath = nodePath.join(workspaceRoot, "toolkit.yaml");
931
1163
  if (await pathExists(toolkitYamlPath)) try {
932
1164
  const content = await fs.readFile(toolkitYamlPath, "utf-8");
933
1165
  const config = yaml.load(content);
@@ -939,14 +1171,14 @@ async function detectProjectType(workspaceRoot) {
939
1171
  };
940
1172
  }
941
1173
  } catch {}
942
- for (const filename of MONOREPO_INDICATOR_FILES) if (await pathExists(path.join(workspaceRoot, filename))) {
1174
+ for (const filename of MONOREPO_INDICATOR_FILES) if (await pathExists(nodePath.join(workspaceRoot, filename))) {
943
1175
  indicators.push(`${filename} found`);
944
1176
  return {
945
1177
  projectType: ProjectType.MONOREPO,
946
1178
  indicators
947
1179
  };
948
1180
  }
949
- const packageJsonPath = path.join(workspaceRoot, "package.json");
1181
+ const packageJsonPath = nodePath.join(workspaceRoot, "package.json");
950
1182
  if (await pathExists(packageJsonPath)) try {
951
1183
  if ((await readJson(packageJsonPath)).workspaces) {
952
1184
  indicators.push("package.json with workspaces found");
@@ -962,26 +1194,6 @@ async function detectProjectType(workspaceRoot) {
962
1194
  indicators
963
1195
  };
964
1196
  }
965
- /**
966
- * Check if a workspace is a monorepo
967
- * Convenience function that returns a boolean
968
- *
969
- * @param workspaceRoot - Absolute path to the workspace root directory
970
- * @returns True if workspace is detected as monorepo, false otherwise
971
- */
972
- async function isMonorepo(workspaceRoot) {
973
- return (await detectProjectType(workspaceRoot)).projectType === ProjectType.MONOREPO;
974
- }
975
- /**
976
- * Check if a workspace is a monolith
977
- * Convenience function that returns a boolean
978
- *
979
- * @param workspaceRoot - Absolute path to the workspace root directory
980
- * @returns True if workspace is detected as monolith, false otherwise
981
- */
982
- async function isMonolith(workspaceRoot) {
983
- return (await detectProjectType(workspaceRoot)).projectType === ProjectType.MONOLITH;
984
- }
985
1197
 
986
1198
  //#endregion
987
- export { ConfigSource, ProjectConfigResolver, ProjectFinderService, ProjectType, ScaffoldProcessingService, TemplatesManagerService, accessSync, copy, cp, detectProjectType, ensureDir, ensureDirSync, fs, generateStableId, icons, isMonolith, isMonorepo, log, logger, messages, mkdir, mkdirSync, move, outputFile, pathExists, pathExistsSync, print, readFile, readFileSync, readJson, readJsonSync, readdir, remove, rename, rm, sections, stat, statSync, unlink, writeFile, writeFileSync, writeJson, writeJsonSync };
1199
+ export { ConfigSource, ProjectConfigResolver, ProjectFinderService, ProjectType, ScaffoldProcessingService, TemplatesManagerService, accessSync, cloneRepository, cloneSubdirectory, copy, detectProjectType, ensureDir, fetchGitHubDirectoryContents, findWorkspaceRoot, generateStableId, gitInit, icons, log, logger, messages, mkdir, mkdirSync, move, parseGitHubUrl, pathExists, pathExistsSync, print, readFile, readFileSync, readJson, readJsonSync, readdir, remove, sections, stat, statSync, writeFile, writeFileSync };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@agiflowai/aicode-utils",
3
3
  "description": "Shared utilities and types for AI-powered code generation, scaffolding, and analysis",
4
- "version": "1.0.7",
4
+ "version": "1.0.9",
5
5
  "license": "AGPL-3.0",
6
6
  "author": "AgiflowIO",
7
7
  "repository": {
@@ -31,7 +31,8 @@
31
31
  ],
32
32
  "dependencies": {
33
33
  "chalk": "5.6.2",
34
- "js-yaml": "4.1.0",
34
+ "execa": "^9.5.2",
35
+ "js-yaml": "4.1.1",
35
36
  "ora": "^9.0.0",
36
37
  "pino": "^10.0.0"
37
38
  },
@@ -43,7 +44,7 @@
43
44
  "chance": "^1.1.13",
44
45
  "tsdown": "^0.16.4",
45
46
  "typescript": "5.9.3",
46
- "vitest": "^3.0.0"
47
+ "vitest": "4.0.15"
47
48
  },
48
49
  "type": "module",
49
50
  "publishConfig": {