@agiflowai/aicode-utils 1.0.8 → 1.0.10

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.cjs CHANGED
@@ -36,6 +36,7 @@ let pino = require("pino");
36
36
  pino = __toESM(pino);
37
37
  let js_yaml = require("js-yaml");
38
38
  js_yaml = __toESM(js_yaml);
39
+ let execa = require("execa");
39
40
  let chalk = require("chalk");
40
41
  chalk = __toESM(chalk);
41
42
 
@@ -92,12 +93,6 @@ async function ensureDir(dirPath) {
92
93
  await node_fs_promises.mkdir(dirPath, { recursive: true });
93
94
  }
94
95
  /**
95
- * Ensure a directory exists (sync), creating it recursively if needed
96
- */
97
- function ensureDirSync(dirPath) {
98
- (0, node_fs.mkdirSync)(dirPath, { recursive: true });
99
- }
100
- /**
101
96
  * Remove a file or directory recursively
102
97
  */
103
98
  async function remove(filePath) {
@@ -140,19 +135,6 @@ async function writeJson(filePath, data, options) {
140
135
  const content = JSON.stringify(data, null, options?.spaces ?? 2);
141
136
  await node_fs_promises.writeFile(filePath, `${content}\n`, "utf-8");
142
137
  }
143
- /**
144
- * Write an object as JSON to a file (sync)
145
- */
146
- function writeJsonSync(filePath, data, options) {
147
- (0, node_fs.writeFileSync)(filePath, `${JSON.stringify(data, null, options?.spaces ?? 2)}\n`, "utf-8");
148
- }
149
- /**
150
- * Output file - writes content ensuring directory exists
151
- */
152
- async function outputFile(filePath, content) {
153
- await ensureDir(node_path.default.dirname(filePath));
154
- await node_fs_promises.writeFile(filePath, content, "utf-8");
155
- }
156
138
  const readFile = node_fs_promises.readFile;
157
139
  const writeFile = node_fs_promises.writeFile;
158
140
  const readdir = node_fs_promises.readdir;
@@ -222,8 +204,7 @@ var TemplatesManagerService = class TemplatesManagerService {
222
204
  * 4. Verify the templates directory exists
223
205
  *
224
206
  * @param startPath - The path to start searching from (defaults to process.cwd())
225
- * @returns The absolute path to the templates directory
226
- * @throws Error if templates directory is not found
207
+ * @returns The absolute path to the templates directory, or null if not found
227
208
  */
228
209
  static async findTemplatesPath(startPath = process.cwd()) {
229
210
  const workspaceRoot = await TemplatesManagerService.findWorkspaceRoot(startPath);
@@ -235,12 +216,12 @@ var TemplatesManagerService = class TemplatesManagerService {
235
216
  if (config?.templatesPath) {
236
217
  const templatesPath$1 = node_path.default.isAbsolute(config.templatesPath) ? config.templatesPath : node_path.default.join(workspaceRoot, config.templatesPath);
237
218
  if (await pathExists(templatesPath$1)) return templatesPath$1;
238
- else throw new Error(`Templates path specified in toolkit.yaml does not exist: ${templatesPath$1}`);
219
+ else return null;
239
220
  }
240
221
  }
241
222
  const templatesPath = node_path.default.join(workspaceRoot, TemplatesManagerService.TEMPLATES_FOLDER);
242
223
  if (await pathExists(templatesPath)) return templatesPath;
243
- throw new Error(`Templates folder not found at ${templatesPath}.\nEither create a 'templates' folder or specify templatesPath in toolkit.yaml`);
224
+ return null;
244
225
  }
245
226
  /**
246
227
  * Find the workspace root by searching upwards for .git folder
@@ -259,8 +240,7 @@ var TemplatesManagerService = class TemplatesManagerService {
259
240
  * Use this when you need immediate access and are sure templates exist.
260
241
  *
261
242
  * @param startPath - The path to start searching from (defaults to process.cwd())
262
- * @returns The absolute path to the templates directory
263
- * @throws Error if templates directory is not found
243
+ * @returns The absolute path to the templates directory, or null if not found
264
244
  */
265
245
  static findTemplatesPathSync(startPath = process.cwd()) {
266
246
  const workspaceRoot = TemplatesManagerService.findWorkspaceRootSync(startPath);
@@ -272,12 +252,12 @@ var TemplatesManagerService = class TemplatesManagerService {
272
252
  if (config?.templatesPath) {
273
253
  const templatesPath$1 = node_path.default.isAbsolute(config.templatesPath) ? config.templatesPath : node_path.default.join(workspaceRoot, config.templatesPath);
274
254
  if (pathExistsSync(templatesPath$1)) return templatesPath$1;
275
- else throw new Error(`Templates path specified in toolkit.yaml does not exist: ${templatesPath$1}`);
255
+ else return null;
276
256
  }
277
257
  }
278
258
  const templatesPath = node_path.default.join(workspaceRoot, TemplatesManagerService.TEMPLATES_FOLDER);
279
259
  if (pathExistsSync(templatesPath)) return templatesPath;
280
- throw new Error(`Templates folder not found at ${templatesPath}.\nEither create a 'templates' folder or specify templatesPath in toolkit.yaml`);
260
+ return null;
281
261
  }
282
262
  /**
283
263
  * Find the workspace root synchronously by searching upwards for .git folder
@@ -782,6 +762,258 @@ function generateStableId(length = 8) {
782
762
  return result;
783
763
  }
784
764
 
765
+ //#endregion
766
+ //#region src/utils/git.ts
767
+ /**
768
+ * Git Utilities
769
+ *
770
+ * DESIGN PATTERNS:
771
+ * - Safe command execution: Use execa with array arguments to prevent shell injection
772
+ * - Defense in depth: Use '--' separator to prevent option injection attacks
773
+ *
774
+ * CODING STANDARDS:
775
+ * - All git commands must use execGit helper with array arguments
776
+ * - Use '--' separator before user-provided arguments (URLs, branches, paths)
777
+ * - Validate inputs where appropriate
778
+ *
779
+ * AVOID:
780
+ * - Shell string interpolation
781
+ * - Passing unsanitized user input as command options
782
+ *
783
+ * NOTE: These utilities perform I/O operations (git commands, file system) by necessity.
784
+ * Pure utility functions like parseGitHubUrl are side-effect free.
785
+ */
786
+ /**
787
+ * Type guard to check if an error has the expected execa error shape
788
+ * @param error - The error to check
789
+ * @returns True if the error has message property (and optionally stderr)
790
+ * @example
791
+ * try {
792
+ * await execa('git', ['status']);
793
+ * } catch (error) {
794
+ * if (isExecaError(error)) {
795
+ * console.error(error.stderr || error.message);
796
+ * }
797
+ * }
798
+ */
799
+ function isExecaError(error) {
800
+ if (typeof error !== "object" || error === null) return false;
801
+ if (!("message" in error)) return false;
802
+ return typeof error.message === "string";
803
+ }
804
+ /**
805
+ * Execute a git command safely using execa to prevent command injection
806
+ * @param args - Array of git command arguments
807
+ * @param cwd - Optional working directory for the command
808
+ * @throws Error when git command fails
809
+ */
810
+ async function execGit(args, cwd) {
811
+ try {
812
+ await (0, execa.execa)("git", args, { cwd });
813
+ } catch (error) {
814
+ if (isExecaError(error)) throw new Error(`Git command failed: ${error.stderr || error.message}`);
815
+ throw error;
816
+ }
817
+ }
818
+ /**
819
+ * Execute git init safely using execa to prevent command injection
820
+ * Uses '--' to prevent projectPath from being interpreted as an option
821
+ * @param projectPath - Path where to initialize the git repository
822
+ * @throws Error when git init fails
823
+ */
824
+ async function gitInit(projectPath) {
825
+ try {
826
+ await (0, execa.execa)("git", [
827
+ "init",
828
+ "--",
829
+ projectPath
830
+ ]);
831
+ } catch (error) {
832
+ if (isExecaError(error)) throw new Error(`Git init failed: ${error.stderr || error.message}`);
833
+ throw error;
834
+ }
835
+ }
836
+ /**
837
+ * Find the workspace root by searching upwards for .git folder
838
+ * Returns null if no .git folder is found (indicating a new project setup is needed)
839
+ * @param startPath - The path to start searching from (default: current working directory)
840
+ * @returns The workspace root path or null if not in a git repository
841
+ * @example
842
+ * const root = await findWorkspaceRoot('/path/to/project/src');
843
+ * if (root) {
844
+ * console.log('Workspace root:', root);
845
+ * } else {
846
+ * console.log('No git repository found');
847
+ * }
848
+ */
849
+ async function findWorkspaceRoot(startPath = process.cwd()) {
850
+ let currentPath = node_path.default.resolve(startPath);
851
+ const rootPath = node_path.default.parse(currentPath).root;
852
+ while (true) {
853
+ if (await pathExists(node_path.default.join(currentPath, ".git"))) return currentPath;
854
+ if (currentPath === rootPath) return null;
855
+ currentPath = node_path.default.dirname(currentPath);
856
+ }
857
+ }
858
+ /**
859
+ * Parse GitHub URL to detect if it's a subdirectory
860
+ * Supports formats:
861
+ * - https://github.com/user/repo
862
+ * - https://github.com/user/repo/tree/branch/path/to/dir
863
+ * - https://github.com/user/repo/tree/main/path/to/dir
864
+ * @param url - The GitHub URL to parse
865
+ * @returns Parsed URL components including owner, repo, branch, and subdirectory
866
+ * @example
867
+ * const result = parseGitHubUrl('https://github.com/user/repo/tree/main/src');
868
+ * // result.owner === 'user'
869
+ * // result.repo === 'repo'
870
+ * // result.branch === 'main'
871
+ * // result.subdirectory === 'src'
872
+ * // result.isSubdirectory === true
873
+ */
874
+ function parseGitHubUrl(url) {
875
+ const treeMatch = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/tree\/([^/]+)\/(.+)$/);
876
+ const blobMatch = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/blob\/([^/]+)\/(.+)$/);
877
+ const rootMatch = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/);
878
+ if (treeMatch || blobMatch) {
879
+ const match = treeMatch || blobMatch;
880
+ return {
881
+ owner: match?.[1],
882
+ repo: match?.[2],
883
+ repoUrl: `https://github.com/${match?.[1]}/${match?.[2]}.git`,
884
+ branch: match?.[3],
885
+ subdirectory: match?.[4],
886
+ isSubdirectory: true
887
+ };
888
+ }
889
+ if (rootMatch) return {
890
+ owner: rootMatch[1],
891
+ repo: rootMatch[2],
892
+ repoUrl: `https://github.com/${rootMatch[1]}/${rootMatch[2]}.git`,
893
+ isSubdirectory: false
894
+ };
895
+ return {
896
+ repoUrl: url,
897
+ isSubdirectory: false
898
+ };
899
+ }
900
+ /**
901
+ * Clone a subdirectory from a git repository using sparse checkout
902
+ * Uses '--' to mark end of options - prevents malicious URLs like '--upload-pack=evil'
903
+ * from being interpreted as git options
904
+ * @param repoUrl - The git repository URL
905
+ * @param branch - The branch to clone from
906
+ * @param subdirectory - The subdirectory path within the repository
907
+ * @param targetFolder - The local folder to clone into
908
+ * @throws Error if subdirectory not found or target folder already exists
909
+ * @example
910
+ * await cloneSubdirectory(
911
+ * 'https://github.com/user/repo.git',
912
+ * 'main',
913
+ * 'packages/core',
914
+ * './my-project'
915
+ * );
916
+ */
917
+ async function cloneSubdirectory(repoUrl, branch, subdirectory, targetFolder) {
918
+ const tempFolder = `${targetFolder}.tmp`;
919
+ try {
920
+ await execGit([
921
+ "init",
922
+ "--",
923
+ tempFolder
924
+ ]);
925
+ await execGit([
926
+ "remote",
927
+ "add",
928
+ "origin",
929
+ "--",
930
+ repoUrl
931
+ ], tempFolder);
932
+ await execGit([
933
+ "config",
934
+ "core.sparseCheckout",
935
+ "true"
936
+ ], tempFolder);
937
+ await writeFile(node_path.default.join(tempFolder, ".git", "info", "sparse-checkout"), `${subdirectory}\n`);
938
+ await execGit([
939
+ "pull",
940
+ "--depth=1",
941
+ "origin",
942
+ "--",
943
+ branch
944
+ ], tempFolder);
945
+ const sourceDir = node_path.default.join(tempFolder, subdirectory);
946
+ if (!await pathExists(sourceDir)) throw new Error(`Subdirectory '${subdirectory}' not found in repository at branch '${branch}'`);
947
+ if (await pathExists(targetFolder)) throw new Error(`Target folder already exists: ${targetFolder}`);
948
+ await move(sourceDir, targetFolder);
949
+ await remove(tempFolder);
950
+ } catch (error) {
951
+ if (await pathExists(tempFolder)) await remove(tempFolder);
952
+ throw error;
953
+ }
954
+ }
955
+ /**
956
+ * Clone entire repository
957
+ * Uses '--' to mark end of options - prevents malicious URLs like '--upload-pack=evil'
958
+ * from being interpreted as git options
959
+ * @param repoUrl - The git repository URL to clone
960
+ * @param targetFolder - The local folder path to clone into
961
+ * @throws Error if git clone fails
962
+ * @example
963
+ * await cloneRepository('https://github.com/user/repo.git', './my-project');
964
+ */
965
+ async function cloneRepository(repoUrl, targetFolder) {
966
+ await execGit([
967
+ "clone",
968
+ "--",
969
+ repoUrl,
970
+ targetFolder
971
+ ]);
972
+ const gitFolder = node_path.default.join(targetFolder, ".git");
973
+ if (await pathExists(gitFolder)) await remove(gitFolder);
974
+ }
975
+ /**
976
+ * Type guard to validate GitHub API content item structure
977
+ * @param item - The item to validate
978
+ * @returns True if the item has required GitHubContentItem properties
979
+ */
980
+ function isGitHubContentItem(item) {
981
+ if (typeof item !== "object" || item === null) return false;
982
+ const obj = item;
983
+ return typeof obj.name === "string" && typeof obj.type === "string" && typeof obj.path === "string";
984
+ }
985
+ /**
986
+ * Fetch directory listing from GitHub API
987
+ * @param owner - The GitHub repository owner/organization
988
+ * @param repo - The repository name
989
+ * @param dirPath - The directory path within the repository
990
+ * @param branch - The branch to fetch from (default: 'main')
991
+ * @returns Array of directory entries with name, type, and path
992
+ * @throws Error if the API request fails or returns non-directory content
993
+ * @remarks
994
+ * - Requires network access to GitHub API
995
+ * - Subject to GitHub API rate limits (60 requests/hour unauthenticated)
996
+ * - Only works with public repositories without authentication
997
+ * @example
998
+ * const contents = await fetchGitHubDirectoryContents('facebook', 'react', 'packages', 'main');
999
+ * // contents: [{ name: 'react', type: 'dir', path: 'packages/react' }, ...]
1000
+ */
1001
+ async function fetchGitHubDirectoryContents(owner, repo, dirPath, branch = "main") {
1002
+ const url = `https://api.github.com/repos/${owner}/${repo}/contents/${dirPath}?ref=${branch}`;
1003
+ const response = await fetch(url, { headers: {
1004
+ Accept: "application/vnd.github.v3+json",
1005
+ "User-Agent": "scaffold-mcp"
1006
+ } });
1007
+ if (!response.ok) throw new Error(`Failed to fetch directory contents: ${response.statusText}`);
1008
+ const data = await response.json();
1009
+ if (!Array.isArray(data)) throw new Error("Expected directory but got file");
1010
+ return data.filter(isGitHubContentItem).map((item) => ({
1011
+ name: item.name,
1012
+ type: item.type,
1013
+ path: item.path
1014
+ }));
1015
+ }
1016
+
785
1017
  //#endregion
786
1018
  //#region src/utils/print.ts
787
1019
  /**
@@ -989,26 +1221,6 @@ async function detectProjectType(workspaceRoot) {
989
1221
  indicators
990
1222
  };
991
1223
  }
992
- /**
993
- * Check if a workspace is a monorepo
994
- * Convenience function that returns a boolean
995
- *
996
- * @param workspaceRoot - Absolute path to the workspace root directory
997
- * @returns True if workspace is detected as monorepo, false otherwise
998
- */
999
- async function isMonorepo(workspaceRoot) {
1000
- return (await detectProjectType(workspaceRoot)).projectType === ProjectType.MONOREPO;
1001
- }
1002
- /**
1003
- * Check if a workspace is a monolith
1004
- * Convenience function that returns a boolean
1005
- *
1006
- * @param workspaceRoot - Absolute path to the workspace root directory
1007
- * @returns True if workspace is detected as monolith, false otherwise
1008
- */
1009
- async function isMonolith(workspaceRoot) {
1010
- return (await detectProjectType(workspaceRoot)).projectType === ProjectType.MONOLITH;
1011
- }
1012
1224
 
1013
1225
  //#endregion
1014
1226
  exports.ConfigSource = ConfigSource;
@@ -1018,28 +1230,23 @@ exports.ProjectType = ProjectType;
1018
1230
  exports.ScaffoldProcessingService = ScaffoldProcessingService;
1019
1231
  exports.TemplatesManagerService = TemplatesManagerService;
1020
1232
  exports.accessSync = node_fs.accessSync;
1233
+ exports.cloneRepository = cloneRepository;
1234
+ exports.cloneSubdirectory = cloneSubdirectory;
1021
1235
  exports.copy = copy;
1022
- exports.cp = cp;
1023
1236
  exports.detectProjectType = detectProjectType;
1024
1237
  exports.ensureDir = ensureDir;
1025
- exports.ensureDirSync = ensureDirSync;
1026
- Object.defineProperty(exports, 'fs', {
1027
- enumerable: true,
1028
- get: function () {
1029
- return node_fs_promises;
1030
- }
1031
- });
1238
+ exports.fetchGitHubDirectoryContents = fetchGitHubDirectoryContents;
1239
+ exports.findWorkspaceRoot = findWorkspaceRoot;
1032
1240
  exports.generateStableId = generateStableId;
1241
+ exports.gitInit = gitInit;
1033
1242
  exports.icons = icons;
1034
- exports.isMonolith = isMonolith;
1035
- exports.isMonorepo = isMonorepo;
1036
1243
  exports.log = log;
1037
1244
  exports.logger = logger;
1038
1245
  exports.messages = messages;
1039
1246
  exports.mkdir = mkdir;
1040
1247
  exports.mkdirSync = node_fs.mkdirSync;
1041
1248
  exports.move = move;
1042
- exports.outputFile = outputFile;
1249
+ exports.parseGitHubUrl = parseGitHubUrl;
1043
1250
  exports.pathExists = pathExists;
1044
1251
  exports.pathExistsSync = pathExistsSync;
1045
1252
  exports.print = print;
@@ -1049,13 +1256,8 @@ exports.readJson = readJson;
1049
1256
  exports.readJsonSync = readJsonSync;
1050
1257
  exports.readdir = readdir;
1051
1258
  exports.remove = remove;
1052
- exports.rename = rename;
1053
- exports.rm = rm;
1054
1259
  exports.sections = sections;
1055
1260
  exports.stat = stat;
1056
1261
  exports.statSync = node_fs.statSync;
1057
- exports.unlink = unlink;
1058
1262
  exports.writeFile = writeFile;
1059
- exports.writeFileSync = node_fs.writeFileSync;
1060
- exports.writeJson = writeJson;
1061
- exports.writeJsonSync = writeJsonSync;
1263
+ exports.writeFileSync = node_fs.writeFileSync;
package/dist/index.d.cts CHANGED
@@ -267,10 +267,9 @@ declare class TemplatesManagerService {
267
267
  * 4. Verify the templates directory exists
268
268
  *
269
269
  * @param startPath - The path to start searching from (defaults to process.cwd())
270
- * @returns The absolute path to the templates directory
271
- * @throws Error if templates directory is not found
270
+ * @returns The absolute path to the templates directory, or null if not found
272
271
  */
273
- static findTemplatesPath(startPath?: string): Promise<string>;
272
+ static findTemplatesPath(startPath?: string): Promise<string | null>;
274
273
  /**
275
274
  * Find the workspace root by searching upwards for .git folder
276
275
  */
@@ -280,10 +279,9 @@ declare class TemplatesManagerService {
280
279
  * Use this when you need immediate access and are sure templates exist.
281
280
  *
282
281
  * @param startPath - The path to start searching from (defaults to process.cwd())
283
- * @returns The absolute path to the templates directory
284
- * @throws Error if templates directory is not found
282
+ * @returns The absolute path to the templates directory, or null if not found
285
283
  */
286
- static findTemplatesPathSync(startPath?: string): string;
284
+ static findTemplatesPathSync(startPath?: string): string | null;
287
285
  /**
288
286
  * Find the workspace root synchronously by searching upwards for .git folder
289
287
  */
@@ -353,10 +351,6 @@ declare function pathExistsSync(filePath: string): boolean;
353
351
  * Ensure a directory exists, creating it recursively if needed
354
352
  */
355
353
  declare function ensureDir(dirPath: string): Promise<void>;
356
- /**
357
- * Ensure a directory exists (sync), creating it recursively if needed
358
- */
359
- declare function ensureDirSync(dirPath: string): void;
360
354
  /**
361
355
  * Remove a file or directory recursively
362
356
  */
@@ -377,31 +371,11 @@ declare function readJson<T = unknown>(filePath: string): Promise<T>;
377
371
  * Read and parse a JSON file (sync)
378
372
  */
379
373
  declare function readJsonSync<T = unknown>(filePath: string): T;
380
- /**
381
- * Write an object as JSON to a file
382
- */
383
- declare function writeJson(filePath: string, data: unknown, options?: {
384
- spaces?: number;
385
- }): Promise<void>;
386
- /**
387
- * Write an object as JSON to a file (sync)
388
- */
389
- declare function writeJsonSync(filePath: string, data: unknown, options?: {
390
- spaces?: number;
391
- }): void;
392
- /**
393
- * Output file - writes content ensuring directory exists
394
- */
395
- declare function outputFile(filePath: string, content: string): Promise<void>;
396
374
  declare const readFile: typeof fs.readFile;
397
375
  declare const writeFile: typeof fs.writeFile;
398
376
  declare const readdir: typeof fs.readdir;
399
377
  declare const mkdir: typeof fs.mkdir;
400
378
  declare const stat: typeof fs.stat;
401
- declare const unlink: typeof fs.unlink;
402
- declare const rename: typeof fs.rename;
403
- declare const rm: typeof fs.rm;
404
- declare const cp: typeof fs.cp;
405
379
  //#endregion
406
380
  //#region src/utils/generateStableId.d.ts
407
381
  /**
@@ -421,6 +395,130 @@ declare const cp: typeof fs.cp;
421
395
  */
422
396
  declare function generateStableId(length?: number): string;
423
397
  //#endregion
398
+ //#region src/utils/git.d.ts
399
+ /**
400
+ * Git Utilities
401
+ *
402
+ * DESIGN PATTERNS:
403
+ * - Safe command execution: Use execa with array arguments to prevent shell injection
404
+ * - Defense in depth: Use '--' separator to prevent option injection attacks
405
+ *
406
+ * CODING STANDARDS:
407
+ * - All git commands must use execGit helper with array arguments
408
+ * - Use '--' separator before user-provided arguments (URLs, branches, paths)
409
+ * - Validate inputs where appropriate
410
+ *
411
+ * AVOID:
412
+ * - Shell string interpolation
413
+ * - Passing unsanitized user input as command options
414
+ *
415
+ * NOTE: These utilities perform I/O operations (git commands, file system) by necessity.
416
+ * Pure utility functions like parseGitHubUrl are side-effect free.
417
+ */
418
+ /**
419
+ * Parsed GitHub URL result
420
+ */
421
+ interface ParsedGitHubUrl {
422
+ owner?: string;
423
+ repo?: string;
424
+ repoUrl: string;
425
+ branch?: string;
426
+ subdirectory?: string;
427
+ isSubdirectory: boolean;
428
+ }
429
+ /**
430
+ * GitHub directory entry
431
+ */
432
+ interface GitHubDirectoryEntry {
433
+ name: string;
434
+ type: string;
435
+ path: string;
436
+ }
437
+ /**
438
+ * Execute git init safely using execa to prevent command injection
439
+ * Uses '--' to prevent projectPath from being interpreted as an option
440
+ * @param projectPath - Path where to initialize the git repository
441
+ * @throws Error when git init fails
442
+ */
443
+ declare function gitInit(projectPath: string): Promise<void>;
444
+ /**
445
+ * Find the workspace root by searching upwards for .git folder
446
+ * Returns null if no .git folder is found (indicating a new project setup is needed)
447
+ * @param startPath - The path to start searching from (default: current working directory)
448
+ * @returns The workspace root path or null if not in a git repository
449
+ * @example
450
+ * const root = await findWorkspaceRoot('/path/to/project/src');
451
+ * if (root) {
452
+ * console.log('Workspace root:', root);
453
+ * } else {
454
+ * console.log('No git repository found');
455
+ * }
456
+ */
457
+ declare function findWorkspaceRoot(startPath?: string): Promise<string | null>;
458
+ /**
459
+ * Parse GitHub URL to detect if it's a subdirectory
460
+ * Supports formats:
461
+ * - https://github.com/user/repo
462
+ * - https://github.com/user/repo/tree/branch/path/to/dir
463
+ * - https://github.com/user/repo/tree/main/path/to/dir
464
+ * @param url - The GitHub URL to parse
465
+ * @returns Parsed URL components including owner, repo, branch, and subdirectory
466
+ * @example
467
+ * const result = parseGitHubUrl('https://github.com/user/repo/tree/main/src');
468
+ * // result.owner === 'user'
469
+ * // result.repo === 'repo'
470
+ * // result.branch === 'main'
471
+ * // result.subdirectory === 'src'
472
+ * // result.isSubdirectory === true
473
+ */
474
+ declare function parseGitHubUrl(url: string): ParsedGitHubUrl;
475
+ /**
476
+ * Clone a subdirectory from a git repository using sparse checkout
477
+ * Uses '--' to mark end of options - prevents malicious URLs like '--upload-pack=evil'
478
+ * from being interpreted as git options
479
+ * @param repoUrl - The git repository URL
480
+ * @param branch - The branch to clone from
481
+ * @param subdirectory - The subdirectory path within the repository
482
+ * @param targetFolder - The local folder to clone into
483
+ * @throws Error if subdirectory not found or target folder already exists
484
+ * @example
485
+ * await cloneSubdirectory(
486
+ * 'https://github.com/user/repo.git',
487
+ * 'main',
488
+ * 'packages/core',
489
+ * './my-project'
490
+ * );
491
+ */
492
+ declare function cloneSubdirectory(repoUrl: string, branch: string, subdirectory: string, targetFolder: string): Promise<void>;
493
+ /**
494
+ * Clone entire repository
495
+ * Uses '--' to mark end of options - prevents malicious URLs like '--upload-pack=evil'
496
+ * from being interpreted as git options
497
+ * @param repoUrl - The git repository URL to clone
498
+ * @param targetFolder - The local folder path to clone into
499
+ * @throws Error if git clone fails
500
+ * @example
501
+ * await cloneRepository('https://github.com/user/repo.git', './my-project');
502
+ */
503
+ declare function cloneRepository(repoUrl: string, targetFolder: string): Promise<void>;
504
+ /**
505
+ * Fetch directory listing from GitHub API
506
+ * @param owner - The GitHub repository owner/organization
507
+ * @param repo - The repository name
508
+ * @param dirPath - The directory path within the repository
509
+ * @param branch - The branch to fetch from (default: 'main')
510
+ * @returns Array of directory entries with name, type, and path
511
+ * @throws Error if the API request fails or returns non-directory content
512
+ * @remarks
513
+ * - Requires network access to GitHub API
514
+ * - Subject to GitHub API rate limits (60 requests/hour unauthenticated)
515
+ * - Only works with public repositories without authentication
516
+ * @example
517
+ * const contents = await fetchGitHubDirectoryContents('facebook', 'react', 'packages', 'main');
518
+ * // contents: [{ name: 'react', type: 'dir', path: 'packages/react' }, ...]
519
+ */
520
+ declare function fetchGitHubDirectoryContents(owner: string, repo: string, dirPath: string, branch?: string): Promise<GitHubDirectoryEntry[]>;
521
+ //#endregion
424
522
  //#region src/utils/logger.d.ts
425
523
  declare const logger: pino.Logger<never, boolean>;
426
524
  declare const log: {
@@ -581,21 +679,5 @@ interface ProjectTypeDetectionResult {
581
679
  * @returns Detection result with project type and indicators
582
680
  */
583
681
  declare function detectProjectType(workspaceRoot: string): Promise<ProjectTypeDetectionResult>;
584
- /**
585
- * Check if a workspace is a monorepo
586
- * Convenience function that returns a boolean
587
- *
588
- * @param workspaceRoot - Absolute path to the workspace root directory
589
- * @returns True if workspace is detected as monorepo, false otherwise
590
- */
591
- declare function isMonorepo(workspaceRoot: string): Promise<boolean>;
592
- /**
593
- * Check if a workspace is a monolith
594
- * Convenience function that returns a boolean
595
- *
596
- * @param workspaceRoot - Absolute path to the workspace root directory
597
- * @returns True if workspace is detected as monolith, false otherwise
598
- */
599
- declare function isMonolith(workspaceRoot: string): Promise<boolean>;
600
682
  //#endregion
601
- export { ConfigSource, GeneratorContext, GeneratorFunction, IFileSystemService, IVariableReplacementService, NxProjectJson, ParsedInclude, ProjectConfig, ProjectConfigResolver, ProjectConfigResult, ProjectFinderService, ProjectType, ProjectTypeDetectionResult, ScaffoldProcessingService, ScaffoldResult, TemplatesManagerService, ToolkitConfig, 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 };
683
+ export { ConfigSource, GeneratorContext, GeneratorFunction, GitHubDirectoryEntry, IFileSystemService, IVariableReplacementService, NxProjectJson, ParsedGitHubUrl, ParsedInclude, ProjectConfig, ProjectConfigResolver, ProjectConfigResult, ProjectFinderService, ProjectType, ScaffoldProcessingService, ScaffoldResult, TemplatesManagerService, ToolkitConfig, 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/dist/index.d.mts CHANGED
@@ -267,10 +267,9 @@ declare class TemplatesManagerService {
267
267
  * 4. Verify the templates directory exists
268
268
  *
269
269
  * @param startPath - The path to start searching from (defaults to process.cwd())
270
- * @returns The absolute path to the templates directory
271
- * @throws Error if templates directory is not found
270
+ * @returns The absolute path to the templates directory, or null if not found
272
271
  */
273
- static findTemplatesPath(startPath?: string): Promise<string>;
272
+ static findTemplatesPath(startPath?: string): Promise<string | null>;
274
273
  /**
275
274
  * Find the workspace root by searching upwards for .git folder
276
275
  */
@@ -280,10 +279,9 @@ declare class TemplatesManagerService {
280
279
  * Use this when you need immediate access and are sure templates exist.
281
280
  *
282
281
  * @param startPath - The path to start searching from (defaults to process.cwd())
283
- * @returns The absolute path to the templates directory
284
- * @throws Error if templates directory is not found
282
+ * @returns The absolute path to the templates directory, or null if not found
285
283
  */
286
- static findTemplatesPathSync(startPath?: string): string;
284
+ static findTemplatesPathSync(startPath?: string): string | null;
287
285
  /**
288
286
  * Find the workspace root synchronously by searching upwards for .git folder
289
287
  */
@@ -353,10 +351,6 @@ declare function pathExistsSync(filePath: string): boolean;
353
351
  * Ensure a directory exists, creating it recursively if needed
354
352
  */
355
353
  declare function ensureDir(dirPath: string): Promise<void>;
356
- /**
357
- * Ensure a directory exists (sync), creating it recursively if needed
358
- */
359
- declare function ensureDirSync(dirPath: string): void;
360
354
  /**
361
355
  * Remove a file or directory recursively
362
356
  */
@@ -377,31 +371,11 @@ declare function readJson<T = unknown>(filePath: string): Promise<T>;
377
371
  * Read and parse a JSON file (sync)
378
372
  */
379
373
  declare function readJsonSync<T = unknown>(filePath: string): T;
380
- /**
381
- * Write an object as JSON to a file
382
- */
383
- declare function writeJson(filePath: string, data: unknown, options?: {
384
- spaces?: number;
385
- }): Promise<void>;
386
- /**
387
- * Write an object as JSON to a file (sync)
388
- */
389
- declare function writeJsonSync(filePath: string, data: unknown, options?: {
390
- spaces?: number;
391
- }): void;
392
- /**
393
- * Output file - writes content ensuring directory exists
394
- */
395
- declare function outputFile(filePath: string, content: string): Promise<void>;
396
374
  declare const readFile: typeof fs.readFile;
397
375
  declare const writeFile: typeof fs.writeFile;
398
376
  declare const readdir: typeof fs.readdir;
399
377
  declare const mkdir: typeof fs.mkdir;
400
378
  declare const stat: typeof fs.stat;
401
- declare const unlink: typeof fs.unlink;
402
- declare const rename: typeof fs.rename;
403
- declare const rm: typeof fs.rm;
404
- declare const cp: typeof fs.cp;
405
379
  //#endregion
406
380
  //#region src/utils/generateStableId.d.ts
407
381
  /**
@@ -421,6 +395,130 @@ declare const cp: typeof fs.cp;
421
395
  */
422
396
  declare function generateStableId(length?: number): string;
423
397
  //#endregion
398
+ //#region src/utils/git.d.ts
399
+ /**
400
+ * Git Utilities
401
+ *
402
+ * DESIGN PATTERNS:
403
+ * - Safe command execution: Use execa with array arguments to prevent shell injection
404
+ * - Defense in depth: Use '--' separator to prevent option injection attacks
405
+ *
406
+ * CODING STANDARDS:
407
+ * - All git commands must use execGit helper with array arguments
408
+ * - Use '--' separator before user-provided arguments (URLs, branches, paths)
409
+ * - Validate inputs where appropriate
410
+ *
411
+ * AVOID:
412
+ * - Shell string interpolation
413
+ * - Passing unsanitized user input as command options
414
+ *
415
+ * NOTE: These utilities perform I/O operations (git commands, file system) by necessity.
416
+ * Pure utility functions like parseGitHubUrl are side-effect free.
417
+ */
418
+ /**
419
+ * Parsed GitHub URL result
420
+ */
421
+ interface ParsedGitHubUrl {
422
+ owner?: string;
423
+ repo?: string;
424
+ repoUrl: string;
425
+ branch?: string;
426
+ subdirectory?: string;
427
+ isSubdirectory: boolean;
428
+ }
429
+ /**
430
+ * GitHub directory entry
431
+ */
432
+ interface GitHubDirectoryEntry {
433
+ name: string;
434
+ type: string;
435
+ path: string;
436
+ }
437
+ /**
438
+ * Execute git init safely using execa to prevent command injection
439
+ * Uses '--' to prevent projectPath from being interpreted as an option
440
+ * @param projectPath - Path where to initialize the git repository
441
+ * @throws Error when git init fails
442
+ */
443
+ declare function gitInit(projectPath: string): Promise<void>;
444
+ /**
445
+ * Find the workspace root by searching upwards for .git folder
446
+ * Returns null if no .git folder is found (indicating a new project setup is needed)
447
+ * @param startPath - The path to start searching from (default: current working directory)
448
+ * @returns The workspace root path or null if not in a git repository
449
+ * @example
450
+ * const root = await findWorkspaceRoot('/path/to/project/src');
451
+ * if (root) {
452
+ * console.log('Workspace root:', root);
453
+ * } else {
454
+ * console.log('No git repository found');
455
+ * }
456
+ */
457
+ declare function findWorkspaceRoot(startPath?: string): Promise<string | null>;
458
+ /**
459
+ * Parse GitHub URL to detect if it's a subdirectory
460
+ * Supports formats:
461
+ * - https://github.com/user/repo
462
+ * - https://github.com/user/repo/tree/branch/path/to/dir
463
+ * - https://github.com/user/repo/tree/main/path/to/dir
464
+ * @param url - The GitHub URL to parse
465
+ * @returns Parsed URL components including owner, repo, branch, and subdirectory
466
+ * @example
467
+ * const result = parseGitHubUrl('https://github.com/user/repo/tree/main/src');
468
+ * // result.owner === 'user'
469
+ * // result.repo === 'repo'
470
+ * // result.branch === 'main'
471
+ * // result.subdirectory === 'src'
472
+ * // result.isSubdirectory === true
473
+ */
474
+ declare function parseGitHubUrl(url: string): ParsedGitHubUrl;
475
+ /**
476
+ * Clone a subdirectory from a git repository using sparse checkout
477
+ * Uses '--' to mark end of options - prevents malicious URLs like '--upload-pack=evil'
478
+ * from being interpreted as git options
479
+ * @param repoUrl - The git repository URL
480
+ * @param branch - The branch to clone from
481
+ * @param subdirectory - The subdirectory path within the repository
482
+ * @param targetFolder - The local folder to clone into
483
+ * @throws Error if subdirectory not found or target folder already exists
484
+ * @example
485
+ * await cloneSubdirectory(
486
+ * 'https://github.com/user/repo.git',
487
+ * 'main',
488
+ * 'packages/core',
489
+ * './my-project'
490
+ * );
491
+ */
492
+ declare function cloneSubdirectory(repoUrl: string, branch: string, subdirectory: string, targetFolder: string): Promise<void>;
493
+ /**
494
+ * Clone entire repository
495
+ * Uses '--' to mark end of options - prevents malicious URLs like '--upload-pack=evil'
496
+ * from being interpreted as git options
497
+ * @param repoUrl - The git repository URL to clone
498
+ * @param targetFolder - The local folder path to clone into
499
+ * @throws Error if git clone fails
500
+ * @example
501
+ * await cloneRepository('https://github.com/user/repo.git', './my-project');
502
+ */
503
+ declare function cloneRepository(repoUrl: string, targetFolder: string): Promise<void>;
504
+ /**
505
+ * Fetch directory listing from GitHub API
506
+ * @param owner - The GitHub repository owner/organization
507
+ * @param repo - The repository name
508
+ * @param dirPath - The directory path within the repository
509
+ * @param branch - The branch to fetch from (default: 'main')
510
+ * @returns Array of directory entries with name, type, and path
511
+ * @throws Error if the API request fails or returns non-directory content
512
+ * @remarks
513
+ * - Requires network access to GitHub API
514
+ * - Subject to GitHub API rate limits (60 requests/hour unauthenticated)
515
+ * - Only works with public repositories without authentication
516
+ * @example
517
+ * const contents = await fetchGitHubDirectoryContents('facebook', 'react', 'packages', 'main');
518
+ * // contents: [{ name: 'react', type: 'dir', path: 'packages/react' }, ...]
519
+ */
520
+ declare function fetchGitHubDirectoryContents(owner: string, repo: string, dirPath: string, branch?: string): Promise<GitHubDirectoryEntry[]>;
521
+ //#endregion
424
522
  //#region src/utils/logger.d.ts
425
523
  declare const logger: pino.Logger<never, boolean>;
426
524
  declare const log: {
@@ -581,21 +679,5 @@ interface ProjectTypeDetectionResult {
581
679
  * @returns Detection result with project type and indicators
582
680
  */
583
681
  declare function detectProjectType(workspaceRoot: string): Promise<ProjectTypeDetectionResult>;
584
- /**
585
- * Check if a workspace is a monorepo
586
- * Convenience function that returns a boolean
587
- *
588
- * @param workspaceRoot - Absolute path to the workspace root directory
589
- * @returns True if workspace is detected as monorepo, false otherwise
590
- */
591
- declare function isMonorepo(workspaceRoot: string): Promise<boolean>;
592
- /**
593
- * Check if a workspace is a monolith
594
- * Convenience function that returns a boolean
595
- *
596
- * @param workspaceRoot - Absolute path to the workspace root directory
597
- * @returns True if workspace is detected as monolith, false otherwise
598
- */
599
- declare function isMonolith(workspaceRoot: string): Promise<boolean>;
600
682
  //#endregion
601
- export { ConfigSource, GeneratorContext, GeneratorFunction, IFileSystemService, IVariableReplacementService, NxProjectJson, ParsedInclude, ProjectConfig, ProjectConfigResolver, ProjectConfigResult, ProjectFinderService, ProjectType, ProjectTypeDetectionResult, ScaffoldProcessingService, ScaffoldResult, TemplatesManagerService, ToolkitConfig, 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 };
683
+ export { ConfigSource, GeneratorContext, GeneratorFunction, type GitHubDirectoryEntry, IFileSystemService, IVariableReplacementService, NxProjectJson, type ParsedGitHubUrl, ParsedInclude, ProjectConfig, ProjectConfigResolver, ProjectConfigResult, ProjectFinderService, ProjectType, ScaffoldProcessingService, ScaffoldResult, TemplatesManagerService, ToolkitConfig, 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/dist/index.mjs CHANGED
@@ -6,6 +6,7 @@ import { accessSync, mkdirSync, readFileSync, readFileSync as readFileSync$1, st
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) {
@@ -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;
@@ -195,8 +177,7 @@ 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);
@@ -208,12 +189,12 @@ var TemplatesManagerService = class TemplatesManagerService {
208
189
  if (config?.templatesPath) {
209
190
  const templatesPath$1 = path.isAbsolute(config.templatesPath) ? config.templatesPath : path.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
195
  const templatesPath = path.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
@@ -232,8 +213,7 @@ 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);
@@ -245,12 +225,12 @@ var TemplatesManagerService = class TemplatesManagerService {
245
225
  if (config?.templatesPath) {
246
226
  const templatesPath$1 = path.isAbsolute(config.templatesPath) ? config.templatesPath : path.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
231
  const templatesPath = path.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
@@ -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 = path.resolve(startPath);
824
+ const rootPath = path.parse(currentPath).root;
825
+ while (true) {
826
+ if (await pathExists(path.join(currentPath, ".git"))) return currentPath;
827
+ if (currentPath === rootPath) return null;
828
+ currentPath = path.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(path.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 = path.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 = path.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
  /**
@@ -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.8",
4
+ "version": "1.0.10",
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": {