@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 +31 -8
- package/dist/cli.js +175 -21
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.js +179 -22
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
|
164
|
-
| `--
|
|
165
|
-
| `--
|
|
166
|
-
| `--
|
|
167
|
-
| `--
|
|
168
|
-
| `--
|
|
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 {
|
|
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((
|
|
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
|
-
|
|
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
|
-
|
|
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((
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
608
|
-
const
|
|
609
|
-
summary({
|
|
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() {
|