@cocreate/cli 1.60.0 → 1.64.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.
@@ -10,21 +10,20 @@ const { isWindows } = getOS();
10
10
  * Resolves the configured target node_modules directory path.
11
11
  * Targeted check: parses RC files only associated with the active package manager.
12
12
  *
13
+ * @param {string} targetDir - Base directory to check for package manager configuration.
13
14
  * @param {string} packageManager - The active package manager name (npm, pnpm, yarn-classic, yarn-berry, bun).
14
15
  * @returns {string} Fully resolved path to the node_modules folder.
15
16
  */
16
- function getTargetNodeModulesPath(packageManager) {
17
- const cwd = process.cwd();
18
-
17
+ function getTargetNodeModulesPath(targetDir, packageManager) {
19
18
  // Check .npmrc config if utilizing npm or pnpm
20
19
  if (packageManager === "npm" || packageManager === "pnpm") {
21
- const npmrcPath = path.join(cwd, ".npmrc");
20
+ const npmrcPath = path.join(targetDir, ".npmrc");
22
21
  if (fs.existsSync(npmrcPath)) {
23
22
  try {
24
23
  const content = fs.readFileSync(npmrcPath, "utf-8");
25
24
  const match = content.match(/^\s*modules-dir\s*=\s*["']?([^"'\r\n#;]+)["']?/m);
26
25
  if (match && match[1]) {
27
- return path.resolve(cwd, match[1].trim());
26
+ return path.resolve(targetDir, match[1].trim());
28
27
  }
29
28
  } catch (e) {
30
29
  // Gracefully ignore reading errors
@@ -34,13 +33,13 @@ function getTargetNodeModulesPath(packageManager) {
34
33
 
35
34
  // Check .yarnrc config if utilizing Yarn (Classic or Berry configured for custom folders)
36
35
  if (packageManager === "yarn-classic" || packageManager === "yarn-berry") {
37
- const yarnrcPath = path.join(cwd, ".yarnrc");
36
+ const yarnrcPath = path.join(targetDir, ".yarnrc");
38
37
  if (fs.existsSync(yarnrcPath)) {
39
38
  try {
40
39
  const content = fs.readFileSync(yarnrcPath, "utf-8");
41
40
  const match = content.match(/^\s*--(?:install\.)?modules-folder\s+["']?([^"'\r\n#]+)["']?/m);
42
41
  if (match && match[1]) {
43
- return path.resolve(cwd, match[1].trim());
42
+ return path.resolve(targetDir, match[1].trim());
44
43
  }
45
44
  } catch (e) {
46
45
  // Gracefully ignore reading errors
@@ -49,29 +48,221 @@ function getTargetNodeModulesPath(packageManager) {
49
48
  }
50
49
 
51
50
  // Fallback to default
52
- return path.resolve(cwd, "node_modules");
51
+ return path.resolve(targetDir, "node_modules");
52
+ }
53
+
54
+ /**
55
+ * Builds a fast lookup dictionary mapping package names (e.g. "@cocreate/utils")
56
+ * to their absolute local filesystem paths.
57
+ *
58
+ * @param {Array<Object>} repos - Array of repository configurations.
59
+ * @param {string} cwd - Active working directory.
60
+ * @returns {Map<string, Object>} Map of package names to repo objects.
61
+ */
62
+ function buildWorkspaceMap(repos, cwd) {
63
+ const map = new Map();
64
+
65
+ for (const repo of repos) {
66
+ if (!repo) continue;
67
+ const rawPath = repo.path || repo.absolutePath || repo.directory;
68
+ if (!rawPath) continue;
69
+
70
+ const absolutePath = repo.absolutePath || path.resolve(cwd, rawPath);
71
+ if (!fs.existsSync(absolutePath)) continue;
72
+
73
+ let packageName = repo.packageName;
74
+ const packageJsonPath = path.join(absolutePath, "package.json");
75
+
76
+ if (fs.existsSync(packageJsonPath)) {
77
+ try {
78
+ const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
79
+ packageName = pkg.name || packageName;
80
+ } catch (e) {
81
+ // Ignore parse errors
82
+ }
83
+ }
84
+
85
+ packageName = packageName || path.basename(absolutePath);
86
+
87
+ const repoMeta = {
88
+ ...repo,
89
+ packageName,
90
+ absolutePath,
91
+ directory: path.dirname(absolutePath),
92
+ name: repo.name || path.basename(absolutePath)
93
+ };
94
+
95
+ map.set(packageName, repoMeta);
96
+ }
97
+
98
+ return map;
99
+ }
100
+
101
+ /**
102
+ * Reads a directory's package.json and extracts all declared dependencies
103
+ * (dependencies, devDependencies, and peerDependencies).
104
+ *
105
+ * @param {string} repoPath - Absolute path to package repository.
106
+ * @returns {Array<string>} List of declared dependency names.
107
+ */
108
+ function getPackageDependencies(repoPath) {
109
+ const packageJsonPath = path.join(repoPath, "package.json");
110
+ if (!fs.existsSync(packageJsonPath)) return [];
111
+
112
+ try {
113
+ const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
114
+ const deps = new Set([
115
+ ...Object.keys(pkg.dependencies || {}),
116
+ ...Object.keys(pkg.devDependencies || {}),
117
+ ...Object.keys(pkg.peerDependencies || {})
118
+ ]);
119
+ return Array.from(deps);
120
+ } catch (e) {
121
+ return [];
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Recursively links a local repository and its local sibling dependencies.
127
+ *
128
+ * @param {Object} repo - Target repository object.
129
+ * @param {Map<string, Object>} workspaceMap - Available local workspace repos.
130
+ * @param {Object} options - Command options (useSymlink, packageManager, binName, failed, linkedPairs, depth).
131
+ * @param {Set<string>} visited - Track visited packages to prevent circular loops.
132
+ */
133
+ async function linkRepoAndDependencies(repo, workspaceMap, options, visited = new Set()) {
134
+ const { useSymlink, packageManager, binName, failed, linkedPairs, stats, depth = 1 } = options;
135
+
136
+ // Circular dependency prevention guard
137
+ if (visited.has(repo.absolutePath)) {
138
+ return;
139
+ }
140
+ visited.add(repo.absolutePath);
141
+
142
+ const declaredDeps = getPackageDependencies(repo.absolutePath);
143
+ const localDepsToLink = [];
144
+
145
+ // Identify which declared dependencies exist in local workspace
146
+ for (const depName of declaredDeps) {
147
+ if (workspaceMap.has(depName)) {
148
+ const depRepo = workspaceMap.get(depName);
149
+ // Avoid self-linking
150
+ if (depRepo.absolutePath !== repo.absolutePath) {
151
+ localDepsToLink.push(depRepo);
152
+ }
153
+ }
154
+ }
155
+
156
+ if (localDepsToLink.length === 0) {
157
+ return;
158
+ }
159
+
160
+ const targetNodeModules = getTargetNodeModulesPath(repo.absolutePath, packageManager);
161
+ const indent = " ".repeat(depth);
162
+
163
+ for (let i = 0; i < localDepsToLink.length; i++) {
164
+ const depRepo = localDepsToLink[i];
165
+ const pairKey = `${repo.packageName}->${depRepo.packageName}`;
166
+
167
+ // Skip re-linking identical pairs to avoid redundant I/O and console clutter
168
+ if (linkedPairs.has(pairKey)) {
169
+ continue;
170
+ }
171
+ linkedPairs.add(pairKey);
172
+
173
+ const isLast = i === localDepsToLink.length - 1;
174
+ const branchChar = isLast ? "└─" : "├─";
175
+
176
+ try {
177
+ if (useSymlink) {
178
+ // Direct Symlink Mode
179
+ const linkDestination = path.join(targetNodeModules, depRepo.packageName);
180
+ const sourcePath = path.resolve(depRepo.absolutePath);
181
+
182
+ await fs.promises.mkdir(path.dirname(linkDestination), { recursive: true });
183
+
184
+ try {
185
+ const stat = await fs.promises.lstat(linkDestination);
186
+ if (stat.isSymbolicLink() || stat.isFile()) {
187
+ await fs.promises.unlink(linkDestination);
188
+ } else {
189
+ await fs.promises.rm(linkDestination, { recursive: true, force: true });
190
+ }
191
+ } catch (e) {
192
+ // Path does not exist yet
193
+ }
194
+
195
+ const type = isWindows ? "junction" : "dir";
196
+ await fs.promises.symlink(sourcePath, linkDestination, type);
197
+
198
+ console.log(`${indent}${branchChar} 🔗 ${depRepo.packageName}`);
199
+ stats.totalSymlinks++;
200
+ } else {
201
+ // Package Manager CLI Link Mode
202
+ if (packageManager === "pnpm" || packageManager === "yarn-berry") {
203
+ let linkCode = await spawn(binName, ["link", depRepo.absolutePath], {
204
+ cwd: repo.absolutePath,
205
+ shell: true,
206
+ stdio: "pipe"
207
+ });
208
+ if (linkCode !== 0) throw new Error(`Link execution failed`);
209
+ } else {
210
+ // 2-Step linking for npm / Yarn Classic / Bun
211
+ let regCode = await spawn(binName, ["link"], {
212
+ cwd: depRepo.absolutePath,
213
+ shell: true,
214
+ stdio: "pipe"
215
+ });
216
+ if (regCode !== 0) throw new Error(`Global registration failed`);
217
+
218
+ let linkCode = await spawn(binName, ["link", depRepo.packageName], {
219
+ cwd: repo.absolutePath,
220
+ shell: true,
221
+ stdio: "pipe"
222
+ });
223
+ if (linkCode !== 0) throw new Error(`Link command failed`);
224
+ }
225
+ console.log(`${indent}${branchChar} 🔗 ${depRepo.packageName}`);
226
+ stats.totalSymlinks++;
227
+ }
228
+
229
+ // Recurse down the dependency tree for the nested dependency
230
+ await linkRepoAndDependencies(depRepo, workspaceMap, {
231
+ ...options,
232
+ depth: depth + 1
233
+ }, visited);
234
+
235
+ } catch (err) {
236
+ failed.push({
237
+ name: `${repo.packageName} -> ${depRepo.packageName}`,
238
+ error: err.message
239
+ });
240
+ console.error(`${indent}${branchChar} ❌ ${depRepo.packageName} (${err.message})`);
241
+ }
242
+ }
53
243
  }
54
244
 
55
245
  /**
56
246
  * CLI-compatible link command that establishes local connections using
57
- * direct symlinking (fast mode) or the host system's package manager.
247
+ * direct symlinking (fast mode) or the host system's package manager,
248
+ * recursively traversing and linking local dependencies tree-wide.
58
249
  *
59
250
  * @param {Array<Object>} repos - Array of repository configurations.
60
251
  * @param {Array<string>} args - Process arguments passed to the CLI command.
61
252
  * @returns {Promise<Array<Object>>} List of packages that encountered real errors.
62
253
  */
63
254
  module.exports = async (repos, args) => {
255
+ const startTime = Date.now();
64
256
  const failed = [];
65
257
  const packageManager = getPackageManager();
66
258
  const cwd = process.cwd();
67
-
68
- // Convert generic internal yarn manager labels to the standard system execution binary name
259
+
260
+ // Convert generic internal yarn manager labels to standard executable binary name
69
261
  const binName = (packageManager === "yarn-classic" || packageManager === "yarn-berry")
70
262
  ? "yarn"
71
263
  : packageManager;
72
-
264
+
73
265
  const useSymlink = args && args.includes("--symlink");
74
- let targetNodeModules = null;
75
266
 
76
267
  // --- PLUG'N'PLAY DETECTION & SHORT-CIRCUIT ---
77
268
  const isPnp = packageManager === "yarn-berry" && (
@@ -85,20 +276,17 @@ module.exports = async (repos, args) => {
85
276
  return failed;
86
277
  }
87
278
 
88
- console.log(`Active Package Manager: ${packageManager.toUpperCase()}`);
279
+ console.log(`\n==================================================`);
280
+ console.log(` Active Package Manager: ${packageManager.toUpperCase()}`);
281
+ console.log(` Linking Strategy: ${useSymlink ? "Direct Symlink (Fast)" : "Package Manager CLI"}`);
282
+ console.log(`==================================================\n`);
89
283
 
90
- if (useSymlink) {
91
- targetNodeModules = getTargetNodeModulesPath(packageManager);
92
- const relativeTargetDir = path.relative(cwd, targetNodeModules) || "node_modules";
93
-
94
- console.log(`[Symlink Mode] Target: ${relativeTargetDir}\n`);
284
+ const workspaceMap = buildWorkspaceMap(repos, cwd);
285
+ const linkedPairs = new Set();
286
+ const stats = { primaryPackages: 0, totalSymlinks: 0 };
95
287
 
96
- if (!fs.existsSync(targetNodeModules)) {
97
- await fs.promises.mkdir(targetNodeModules, { recursive: true });
98
- }
99
- } else {
100
- console.log("");
101
- }
288
+ const filterableRepos = repos.filter(r => r && (!r.exclude || !r.exclude.includes("link")));
289
+ let currentIdx = 0;
102
290
 
103
291
  for (let repo of repos) {
104
292
  if (!repo) continue;
@@ -107,42 +295,33 @@ module.exports = async (repos, args) => {
107
295
  const rawPath = repo.path || repo.absolutePath || repo.directory;
108
296
  if (!rawPath) continue;
109
297
 
110
- // Resolve local path values relative to the active command execution folder
111
- repo.absolutePath = repo.absolutePath || path.resolve(cwd, rawPath);
112
- repo.directory = repo.directory || path.dirname(repo.absolutePath);
113
- repo.name = repo.name || path.basename(repo.absolutePath);
298
+ const absolutePath = repo.absolutePath || path.resolve(cwd, rawPath);
114
299
 
115
- // Do not process current execution folder inside linking commands
116
- if (cwd === repo.absolutePath) continue;
300
+ // Do not process current execution folder directly inside root loop
301
+ if (cwd === absolutePath) continue;
117
302
 
118
- const relativeRepoPath = path.relative(cwd, repo.absolutePath);
119
-
120
- // Verify package directory exists on local disk
121
- if (!fs.existsSync(repo.absolutePath)) {
122
- console.log(`Skipped (not found): ${relativeRepoPath}`);
303
+ if (!fs.existsSync(absolutePath)) {
304
+ console.log(`Skipped (not found): ${path.relative(cwd, absolutePath)}`);
123
305
  continue;
124
306
  }
125
307
 
126
- // Dynamically resolve package name from local package.json if it is missing
127
- if (!repo.packageName) {
128
- const packageJsonPath = path.join(repo.absolutePath, "package.json");
129
- if (fs.existsSync(packageJsonPath)) {
130
- try {
131
- const packageObj = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
132
- repo.packageName = packageObj.name;
133
- } catch (e) {
134
- // Ignore reading errors
135
- }
136
- }
137
- repo.packageName = repo.packageName || repo.name;
138
- }
308
+ currentIdx++;
309
+ const repoMeta = workspaceMap.get(repo.packageName) || {
310
+ ...repo,
311
+ absolutePath,
312
+ packageName: repo.packageName || path.basename(absolutePath)
313
+ };
139
314
 
315
+ stats.primaryPackages++;
316
+ console.log(`[${currentIdx}/${filterableRepos.length}] 📦 ${repoMeta.packageName}`);
317
+
318
+ // Perform top-level link into current workspace root
140
319
  try {
320
+ const rootNodeModules = getTargetNodeModulesPath(cwd, packageManager);
141
321
  if (useSymlink) {
142
- const linkDestination = path.join(targetNodeModules, repo.packageName);
143
- const sourcePath = path.resolve(repo.absolutePath);
144
-
322
+ const linkDestination = path.join(rootNodeModules, repoMeta.packageName);
145
323
  await fs.promises.mkdir(path.dirname(linkDestination), { recursive: true });
324
+
146
325
  try {
147
326
  const stat = await fs.promises.lstat(linkDestination);
148
327
  if (stat.isSymbolicLink() || stat.isFile()) {
@@ -153,65 +332,62 @@ module.exports = async (repos, args) => {
153
332
  } catch (e) {}
154
333
 
155
334
  const type = isWindows ? "junction" : "dir";
156
- await fs.promises.symlink(sourcePath, linkDestination, type);
157
-
158
- console.log(`Successfully symlinked: ${relativeRepoPath} -> node_modules/${repo.packageName}`);
335
+ await fs.promises.symlink(repoMeta.absolutePath, linkDestination, type);
336
+ stats.totalSymlinks++;
159
337
  } else {
160
-
161
- // --- 1-STEP DIRECT PATH LINKING (pnpm & Yarn Berry) ---
162
338
  if (packageManager === "pnpm" || packageManager === "yarn-berry") {
163
- console.log(`Linking: ${repo.packageName}...`);
164
-
165
- let linkCode = await spawn(binName, ["link", repo.absolutePath], {
166
- cwd: cwd,
339
+ let linkCode = await spawn(binName, ["link", repoMeta.absolutePath], {
340
+ cwd,
167
341
  shell: true,
168
- stdio: "inherit"
342
+ stdio: "pipe"
169
343
  });
170
-
171
- if (linkCode !== 0) {
172
- throw new Error(`Execution failed`);
173
- }
174
-
344
+ if (linkCode !== 0) throw new Error("Execution failed");
175
345
  } else {
176
- // --- 2-STEP GLOBAL REGISTRY LINKING (npm, Yarn Classic, Bun) ---
177
- console.log(`Registering: ${repo.packageName}...`);
178
-
179
346
  let regCode = await spawn(binName, ["link"], {
180
- cwd: repo.absolutePath,
347
+ cwd: repoMeta.absolutePath,
181
348
  shell: true,
182
- stdio: "inherit"
349
+ stdio: "pipe"
183
350
  });
351
+ if (regCode !== 0) throw new Error("Global registration failed");
184
352
 
185
- if (regCode !== 0) {
186
- throw new Error(`Global registration failed`);
187
- }
188
-
189
- console.log(`Linking: ${repo.packageName}...`);
190
-
191
- let linkCode = await spawn(binName, ["link", repo.packageName], {
192
- cwd: cwd,
353
+ let linkCode = await spawn(binName, ["link", repoMeta.packageName], {
354
+ cwd,
193
355
  shell: true,
194
- stdio: "inherit"
356
+ stdio: "pipe"
195
357
  });
196
-
197
- if (linkCode !== 0) {
198
- throw new Error(`Host linkage failed`);
199
- }
358
+ if (linkCode !== 0) throw new Error("Host linkage failed");
200
359
  }
201
-
202
- console.log(`Successfully linked: ${repo.packageName}`);
203
- console.log("");
360
+ stats.totalSymlinks++;
204
361
  }
205
-
206
362
  } catch (err) {
207
- // Log failure message and append package metadata for downstream retrying steps
208
363
  failed.push({
209
- name: repo.name || repo.packageName,
364
+ name: repoMeta.packageName,
210
365
  error: err.message
211
366
  });
212
- console.error(`Failed to link: ${repo.name || repo.packageName} (${err.message})`);
367
+ console.error(`Failed primary link: ${repoMeta.packageName} (${err.message})`);
213
368
  }
369
+
370
+ const visited = new Set();
371
+ await linkRepoAndDependencies(repoMeta, workspaceMap, {
372
+ useSymlink,
373
+ packageManager,
374
+ binName,
375
+ failed,
376
+ linkedPairs,
377
+ stats,
378
+ depth: 1
379
+ }, visited);
214
380
  }
215
381
 
382
+ const durationSec = ((Date.now() - startTime) / 1000).toFixed(2);
383
+
384
+ console.log(`\n--------------------------------------------------`);
385
+ console.log(`✨ Linking Complete!`);
386
+ console.log(` Primary Packages Processed: ${stats.primaryPackages}`);
387
+ console.log(` Total Connections Linked: ${stats.totalSymlinks}`);
388
+ console.log(` Errors Encountered: ${failed.length}`);
389
+ console.log(` Time Elapsed: ${durationSec}s`);
390
+ console.log(`--------------------------------------------------\n`);
391
+
216
392
  return failed;
217
393
  };