@dafish/gogo-meta 1.4.0 → 1.6.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.
package/README.md CHANGED
@@ -11,6 +11,7 @@ A modern TypeScript CLI for managing multi-repository projects. Execute commands
11
11
  - NPM operations across all projects
12
12
  - Symlink projects for local development
13
13
  - JSON and YAML configuration formats
14
+ - Multiple config files with `-f` flag (like Docker Compose)
14
15
 
15
16
  ## Installation
16
17
 
@@ -142,6 +143,27 @@ commands:
142
143
  - web
143
144
  ```
144
145
 
146
+ ### Multiple Config Files
147
+
148
+ For large projects you can split configuration across multiple files and merge them at runtime using the `-f, --file` flag:
149
+
150
+ ```bash
151
+ # Load primary .gogo plus additional projects from .gogo.devops
152
+ gogo -f .gogo.devops exec "npm test"
153
+
154
+ # Multiple overlays are applied in order
155
+ gogo -f .gogo.devops -f .gogo.extra git status
156
+ ```
157
+
158
+ Overlay files follow the same format as the primary config (JSON or YAML). When merging:
159
+ - **Projects**: overlay entries are added; on key conflict the overlay wins
160
+ - **Ignore**: arrays are concatenated and deduplicated
161
+ - **Commands**: overlay entries are added; on key conflict the overlay wins
162
+
163
+ Overlay paths are resolved relative to the directory containing the primary config file.
164
+
165
+ Write commands (`project create`, `project import`) only modify the primary config file — overlay projects are never absorbed into it.
166
+
145
167
  ### .looprc (optional)
146
168
 
147
169
  Define default ignore patterns for command execution:
@@ -158,14 +180,15 @@ Define default ignore patterns for command execution:
158
180
 
159
181
  These options are available for most commands:
160
182
 
161
- | Option | Description |
162
- | --------------------------- | --------------------------------------------------- |
163
- | `--include-only <dirs>` | Only target specified directories (comma-separated) |
164
- | `--exclude-only <dirs>` | Exclude specified directories (comma-separated) |
165
- | `--include-pattern <regex>` | Include directories matching regex pattern |
166
- | `--exclude-pattern <regex>` | Exclude directories matching regex pattern |
167
- | `--parallel` | Execute commands concurrently |
168
- | `--concurrency <n>` | Maximum parallel processes (default: 4) |
183
+ | Option | Description |
184
+ | --------------------------- | -------------------------------------------------------- |
185
+ | `-f, --file <path>` | Additional config file to merge (repeatable) |
186
+ | `--include-only <dirs>` | Only target specified directories (comma-separated) |
187
+ | `--exclude-only <dirs>` | Exclude specified directories (comma-separated) |
188
+ | `--include-pattern <regex>` | Include directories matching regex pattern |
189
+ | `--exclude-pattern <regex>` | Exclude directories matching regex pattern |
190
+ | `--parallel` | Execute commands concurrently |
191
+ | `--concurrency <n>` | Maximum parallel processes (default: 4) |
169
192
 
170
193
  ---
171
194
 
package/dist/cli.js CHANGED
@@ -2,9 +2,9 @@ import { spawn, execSync } from 'child_process';
2
2
  import { Command } from 'commander';
3
3
  import { readFileSync } from 'fs';
4
4
  import { fileURLToPath } from 'url';
5
- import { dirname, join, basename } from 'path';
6
- import { unlink, mkdir, appendFile, access, writeFile, readFile, symlink } from 'fs/promises';
7
- import { stringify, parse } from 'yaml';
5
+ import { dirname, join, basename, resolve } from 'path';
6
+ import { unlink, mkdir, appendFile, access, writeFile, readFile, symlink, readdir } from 'fs/promises';
7
+ import { parse, stringify } from 'yaml';
8
8
  import { z } from 'zod';
9
9
  import pc from 'picocolors';
10
10
  import { homedir } from 'os';
@@ -28,7 +28,7 @@ __export(executor_exports, {
28
28
  });
29
29
  async function execute(command, options) {
30
30
  const { cwd, env = process.env, timeout = DEFAULT_TIMEOUT, shell = true } = options;
31
- return new Promise((resolve) => {
31
+ return new Promise((resolve2) => {
32
32
  let stdout = "";
33
33
  let stderr = "";
34
34
  let timedOut = false;
@@ -68,7 +68,7 @@ async function execute(command, options) {
68
68
  });
69
69
  child.on("close", (code) => {
70
70
  clearTimeout(timeoutId);
71
- resolve({
71
+ resolve2({
72
72
  exitCode: code ?? (timedOut ? 124 : 1),
73
73
  stdout: stdout.trim(),
74
74
  stderr: stderr.trim(),
@@ -77,7 +77,7 @@ async function execute(command, options) {
77
77
  });
78
78
  child.on("error", (err) => {
79
79
  clearTimeout(timeoutId);
80
- resolve({
80
+ resolve2({
81
81
  exitCode: 1,
82
82
  stdout: "",
83
83
  stderr: err.message,
@@ -120,7 +120,7 @@ function executeSync(command, options) {
120
120
  }
121
121
  async function executeStreaming(command, options) {
122
122
  const { cwd, env = process.env, timeout = DEFAULT_TIMEOUT, shell = true, onStdout, onStderr } = options;
123
- return new Promise((resolve) => {
123
+ return new Promise((resolve2) => {
124
124
  let stdout = "";
125
125
  let stderr = "";
126
126
  let timedOut = false;
@@ -159,7 +159,7 @@ async function executeStreaming(command, options) {
159
159
  });
160
160
  child.on("close", (code) => {
161
161
  clearTimeout(timeoutId);
162
- resolve({
162
+ resolve2({
163
163
  exitCode: code ?? (timedOut ? 124 : 1),
164
164
  stdout: stdout.trim(),
165
165
  stderr: stderr.trim(),
@@ -168,7 +168,7 @@ async function executeStreaming(command, options) {
168
168
  });
169
169
  child.on("error", (err) => {
170
170
  clearTimeout(timeoutId);
171
- resolve({
171
+ resolve2({
172
172
  exitCode: 1,
173
173
  stdout: "",
174
174
  stderr: err.message,
@@ -232,6 +232,23 @@ function parseContent(content, format) {
232
232
  function serializeContent(data, format) {
233
233
  return format === "yaml" ? stringify(data, { indent: 2 }) : JSON.stringify(data, null, 2) + "\n";
234
234
  }
235
+ var _overlayFiles = [];
236
+ function setOverlayFiles(files) {
237
+ _overlayFiles = files;
238
+ }
239
+ function mergeConfigs(base, overlay) {
240
+ return {
241
+ projects: {
242
+ ...base.projects,
243
+ ...overlay.projects
244
+ },
245
+ ignore: [.../* @__PURE__ */ new Set([...base.ignore, ...overlay.ignore])],
246
+ commands: base.commands || overlay.commands ? {
247
+ ...base.commands,
248
+ ...overlay.commands
249
+ } : void 0
250
+ };
251
+ }
235
252
  async function fileExists(path) {
236
253
  try {
237
254
  await access(path);
@@ -270,7 +287,29 @@ async function findMetaFileUp(startDir) {
270
287
  currentDir = parentDir;
271
288
  }
272
289
  }
273
- async function readMetaConfig(cwd) {
290
+ async function readOverlayConfig(filePath) {
291
+ if (!await fileExists(filePath)) {
292
+ throw new ConfigError(`Overlay config file not found: ${filePath}`, filePath);
293
+ }
294
+ const format = detectFormat(filePath);
295
+ try {
296
+ const content = await readFile(filePath, "utf-8");
297
+ const parsed = parseContent(content, format);
298
+ return MetaConfigSchema.parse(parsed);
299
+ } catch (error2) {
300
+ if (error2 instanceof SyntaxError) {
301
+ throw new ConfigError(`Invalid JSON in overlay config file`, filePath);
302
+ }
303
+ if (error2 instanceof Error && error2.name === "YAMLParseError") {
304
+ throw new ConfigError(`Invalid YAML in overlay config file`, filePath);
305
+ }
306
+ if (error2 instanceof Error && error2.name === "ZodError") {
307
+ throw new ConfigError(`Invalid overlay config file structure: ${error2.message}`, filePath);
308
+ }
309
+ throw error2;
310
+ }
311
+ }
312
+ async function readMetaConfig(cwd, overlayFiles) {
274
313
  const metaPath = await findMetaFileUp(cwd);
275
314
  if (!metaPath) {
276
315
  throw new ConfigError(
@@ -278,11 +317,12 @@ async function readMetaConfig(cwd) {
278
317
  );
279
318
  }
280
319
  const format = detectFormat(metaPath);
320
+ const metaDir = dirname(metaPath);
321
+ let config;
281
322
  try {
282
323
  const content = await readFile(metaPath, "utf-8");
283
324
  const parsed = parseContent(content, format);
284
- const config = MetaConfigSchema.parse(parsed);
285
- return { config, format, metaDir: dirname(metaPath) };
325
+ config = MetaConfigSchema.parse(parsed);
286
326
  } catch (error2) {
287
327
  if (error2 instanceof SyntaxError) {
288
328
  throw new ConfigError(`Invalid JSON in config file`, metaPath);
@@ -295,6 +335,13 @@ async function readMetaConfig(cwd) {
295
335
  }
296
336
  throw error2;
297
337
  }
338
+ const filesToMerge = overlayFiles ?? _overlayFiles;
339
+ for (const overlayRelPath of filesToMerge) {
340
+ const overlayPath = resolve(metaDir, overlayRelPath);
341
+ const overlayConfig = await readOverlayConfig(overlayPath);
342
+ config = mergeConfigs(config, overlayConfig);
343
+ }
344
+ return { config, format, metaDir };
298
345
  }
299
346
  async function writeMetaConfig(cwd, config, format = "json") {
300
347
  const filename = filenameForFormat(format);
@@ -423,7 +470,7 @@ function commandOutput(stdout, stderr) {
423
470
  }
424
471
  }
425
472
  function summary(results) {
426
- const { success: successCount, failed, total } = results;
473
+ const { success: successCount, failed, total, failedProjects } = results;
427
474
  console.log("");
428
475
  if (failed === 0) {
429
476
  console.log(`${symbols.success} ${pc.green(`All ${total} projects completed successfully`)}`);
@@ -431,6 +478,13 @@ function summary(results) {
431
478
  console.log(
432
479
  `${symbols.warning} ${pc.yellow(`${successCount}/${total} projects succeeded, ${failed} failed`)}`
433
480
  );
481
+ if (failedProjects && failedProjects.length > 0) {
482
+ console.log("");
483
+ console.log("Failed projects:");
484
+ for (const project of failedProjects) {
485
+ console.log(` ${symbols.error} ${pc.red(project)}`);
486
+ }
487
+ }
434
488
  }
435
489
  }
436
490
 
@@ -604,9 +658,14 @@ async function loop(command, context, options = {}) {
604
658
  }
605
659
  const results = options.parallel ? await runParallel(command, directories, context, options) : await runSequential(command, directories, context, options);
606
660
  if (!options.suppressOutput) {
607
- const successCount = results.filter((r) => r.success).length;
608
- const failedCount = results.length - successCount;
609
- summary({ success: successCount, failed: failedCount, total: results.length });
661
+ const failedResults = results.filter((r) => !r.success);
662
+ const successCount = results.length - failedResults.length;
663
+ summary({
664
+ success: successCount,
665
+ failed: failedResults.length,
666
+ total: results.length,
667
+ failedProjects: failedResults.map((r) => r.directory)
668
+ });
610
669
  }
611
670
  return results;
612
671
  }
@@ -1121,7 +1180,7 @@ async function createCommand(folder, url) {
1121
1180
  if (remoteResult.exitCode !== 0) {
1122
1181
  throw new Error(`Failed to add remote: ${remoteResult.stderr}`);
1123
1182
  }
1124
- const { config, format } = await readMetaConfig(metaDir);
1183
+ const { config, format } = await readMetaConfig(metaDir, []);
1125
1184
  const updatedConfig = addProject(config, folder, url);
1126
1185
  await writeMetaConfig(metaDir, updatedConfig, format);
1127
1186
  const gitignorePath = join(metaDir, ".gitignore");
@@ -1165,7 +1224,7 @@ async function importCommand(folder, url, options = {}) {
1165
1224
  info(`Existing: ${existingUrl}`);
1166
1225
  info(`Provided: ${url}`);
1167
1226
  }
1168
- const { config, format } = await readMetaConfig(metaDir);
1227
+ const { config, format } = await readMetaConfig(metaDir, []);
1169
1228
  const updatedConfig = addProject(config, folder, finalUrl);
1170
1229
  await writeMetaConfig(metaDir, updatedConfig, format);
1171
1230
  success(`Imported existing project "${folder}"`);
@@ -1175,7 +1234,7 @@ async function importCommand(folder, url, options = {}) {
1175
1234
  throw new Error("URL is required when importing a non-existent project");
1176
1235
  }
1177
1236
  if (options.noClone) {
1178
- const { config: config2, format: format2 } = await readMetaConfig(metaDir);
1237
+ const { config: config2, format: format2 } = await readMetaConfig(metaDir, []);
1179
1238
  const updatedConfig2 = addProject(config2, folder, url);
1180
1239
  await writeMetaConfig(metaDir, updatedConfig2, format2);
1181
1240
  const added2 = await addToGitignore(metaDir, folder);
@@ -1195,7 +1254,7 @@ async function importCommand(folder, url, options = {}) {
1195
1254
  if (cloneResult.exitCode !== 0) {
1196
1255
  throw new Error(`Failed to clone repository: ${cloneResult.stderr}`);
1197
1256
  }
1198
- const { config, format } = await readMetaConfig(metaDir);
1257
+ const { config, format } = await readMetaConfig(metaDir, []);
1199
1258
  const updatedConfig = addProject(config, folder, url);
1200
1259
  await writeMetaConfig(metaDir, updatedConfig, format);
1201
1260
  success(`Imported project "${folder}"`);
@@ -1402,6 +1461,91 @@ function registerNpmCommands(program) {
1402
1461
  await runCommand2(script, options);
1403
1462
  });
1404
1463
  }
1464
+ function isGogoConfigFile(filename) {
1465
+ return filename === ".gogo" || filename.startsWith(".gogo.");
1466
+ }
1467
+ async function findConfigFiles(cwd) {
1468
+ const entries = await readdir(cwd);
1469
+ const configFiles = [];
1470
+ for (const entry of entries) {
1471
+ if (isGogoConfigFile(entry)) {
1472
+ configFiles.push(entry);
1473
+ }
1474
+ }
1475
+ if (entries.includes(LOOPRC_FILE)) {
1476
+ configFiles.push(LOOPRC_FILE);
1477
+ }
1478
+ return configFiles.sort();
1479
+ }
1480
+ async function validateCommand() {
1481
+ const cwd = process.cwd();
1482
+ const results = [];
1483
+ const configFiles = await findConfigFiles(cwd);
1484
+ for (const filename of configFiles) {
1485
+ const filePath = join(cwd, filename);
1486
+ if (filename === LOOPRC_FILE) {
1487
+ results.push(await validateLoopRcFile(filePath));
1488
+ } else {
1489
+ results.push(await validateConfigFile(filePath, filename));
1490
+ }
1491
+ }
1492
+ if (results.length === 0) {
1493
+ warning("No config files found in current directory");
1494
+ return;
1495
+ }
1496
+ const hasErrors = results.some((r) => !r.valid);
1497
+ for (const result of results) {
1498
+ if (result.valid) {
1499
+ projectStatus(result.file, "success");
1500
+ } else {
1501
+ projectStatus(result.file, "error", result.error);
1502
+ }
1503
+ }
1504
+ if (hasErrors) {
1505
+ throw new Error("Validation failed");
1506
+ }
1507
+ }
1508
+ async function validateConfigFile(filePath, filename) {
1509
+ const format = detectFormat(filePath);
1510
+ try {
1511
+ const content = await readFile(filePath, "utf-8");
1512
+ const parsed = format === "yaml" ? parse(content) : JSON.parse(content);
1513
+ MetaConfigSchema.parse(parsed);
1514
+ return { file: filename, valid: true };
1515
+ } catch (error2) {
1516
+ if (error2 instanceof SyntaxError) {
1517
+ return { file: filename, valid: false, error: "Invalid JSON" };
1518
+ }
1519
+ if (error2 instanceof Error && error2.name === "YAMLParseError") {
1520
+ return { file: filename, valid: false, error: "Invalid YAML" };
1521
+ }
1522
+ if (error2 instanceof Error && error2.name === "ZodError") {
1523
+ return { file: filename, valid: false, error: `Invalid structure: ${error2.message}` };
1524
+ }
1525
+ return { file: filename, valid: false, error: String(error2) };
1526
+ }
1527
+ }
1528
+ async function validateLoopRcFile(filePath) {
1529
+ try {
1530
+ const content = await readFile(filePath, "utf-8");
1531
+ const parsed = JSON.parse(content);
1532
+ LoopRcSchema.parse(parsed);
1533
+ return { file: LOOPRC_FILE, valid: true };
1534
+ } catch (error2) {
1535
+ if (error2 instanceof SyntaxError) {
1536
+ return { file: LOOPRC_FILE, valid: false, error: "Invalid JSON" };
1537
+ }
1538
+ if (error2 instanceof Error && error2.name === "ZodError") {
1539
+ return { file: LOOPRC_FILE, valid: false, error: `Invalid structure: ${error2.message}` };
1540
+ }
1541
+ return { file: LOOPRC_FILE, valid: false, error: String(error2) };
1542
+ }
1543
+ }
1544
+ function registerValidateCommand(program) {
1545
+ program.command("validate").description("Validate all config files in the current directory").action(async () => {
1546
+ await validateCommand();
1547
+ });
1548
+ }
1405
1549
 
1406
1550
  // src/cli.ts
1407
1551
  function getVersion() {
@@ -1416,13 +1560,23 @@ function getVersion() {
1416
1560
  }
1417
1561
  function createProgram() {
1418
1562
  const program = new Command();
1419
- program.name("gogo").description("A modern CLI tool for managing multi-repository projects").version(getVersion()).option("--include-only <dirs>", "Only include specified directories (comma-separated)").option("--exclude-only <dirs>", "Exclude specified directories (comma-separated)").option("--include-pattern <regex>", "Include directories matching regex pattern").option("--exclude-pattern <regex>", "Exclude directories matching regex pattern").option("--parallel", "Execute commands in parallel").option("--concurrency <number>", "Max parallel processes (default: 4)", parseInt);
1563
+ program.name("gogo").description("A modern CLI tool for managing multi-repository projects").version(getVersion()).option("--include-only <dirs>", "Only include specified directories (comma-separated)").option("--exclude-only <dirs>", "Exclude specified directories (comma-separated)").option("--include-pattern <regex>", "Include directories matching regex pattern").option("--exclude-pattern <regex>", "Exclude directories matching regex pattern").option("--parallel", "Execute commands in parallel").option("--concurrency <number>", "Max parallel processes (default: 4)", parseInt).option(
1564
+ "-f, --file <path>",
1565
+ "Additional config file to merge (repeatable)",
1566
+ (val, prev) => [...prev, val],
1567
+ []
1568
+ );
1569
+ program.hook("preAction", (thisCommand) => {
1570
+ const opts = thisCommand.optsWithGlobals();
1571
+ setOverlayFiles(opts["file"] ?? []);
1572
+ });
1420
1573
  registerInitCommand(program);
1421
1574
  registerExecCommand(program);
1422
1575
  registerRunCommand(program);
1423
1576
  registerGitCommands(program);
1424
1577
  registerProjectCommands(program);
1425
1578
  registerNpmCommands(program);
1579
+ registerValidateCommand(program);
1426
1580
  return program;
1427
1581
  }
1428
1582
  async function main() {