@saptools/bruno 0.2.4 → 0.2.6
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 +11 -9
- package/dist/cli.js +338 -274
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import process2 from "process";
|
|
5
|
-
import {
|
|
6
|
-
import { Command } from "commander";
|
|
5
|
+
import { confirm, select } from "@inquirer/prompts";
|
|
6
|
+
import { Command, Option } from "commander";
|
|
7
7
|
|
|
8
8
|
// src/context.ts
|
|
9
9
|
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
@@ -61,12 +61,87 @@ async function writeContext(ctx) {
|
|
|
61
61
|
return updated;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
// src/
|
|
65
|
-
import {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
import {
|
|
69
|
-
import {
|
|
64
|
+
// src/environment-prompt.ts
|
|
65
|
+
import { Separator, checkbox, input } from "@inquirer/prompts";
|
|
66
|
+
|
|
67
|
+
// src/setup-app.ts
|
|
68
|
+
import { mkdir as mkdir2, readdir, writeFile as writeFile3 } from "fs/promises";
|
|
69
|
+
import { join as join2 } from "path";
|
|
70
|
+
|
|
71
|
+
// src/cf-info.ts
|
|
72
|
+
import {
|
|
73
|
+
getRegionView as getRegionViewApi,
|
|
74
|
+
readRegionsView,
|
|
75
|
+
readRegionView,
|
|
76
|
+
readStructureView,
|
|
77
|
+
REGION_KEYS
|
|
78
|
+
} from "@saptools/cf-sync";
|
|
79
|
+
var defaultCfInfoDeps = {
|
|
80
|
+
readStructureView,
|
|
81
|
+
readRegionsView,
|
|
82
|
+
readRegionView,
|
|
83
|
+
getRegionView: getRegionViewApi
|
|
84
|
+
};
|
|
85
|
+
function isValidRegionKey(value) {
|
|
86
|
+
return REGION_KEYS.includes(value);
|
|
87
|
+
}
|
|
88
|
+
async function getStructureSnapshot(deps = defaultCfInfoDeps) {
|
|
89
|
+
const view = await deps.readStructureView();
|
|
90
|
+
if (!view) {
|
|
91
|
+
return {
|
|
92
|
+
source: "empty",
|
|
93
|
+
structure: void 0,
|
|
94
|
+
stale: true,
|
|
95
|
+
message: "No CF structure cached. Run `cf-sync sync` first."
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const stale = view.source === "runtime" && view.metadata?.status === "running";
|
|
99
|
+
return {
|
|
100
|
+
source: view.source,
|
|
101
|
+
structure: view.structure,
|
|
102
|
+
stale,
|
|
103
|
+
message: stale ? "A CF sync is still running \u2014 showing partial data." : void 0
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
async function listRegionsWithContent(deps = defaultCfInfoDeps) {
|
|
107
|
+
const snapshot = await getStructureSnapshot(deps);
|
|
108
|
+
if (!snapshot.structure) {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
return snapshot.structure.regions.filter((r) => r.accessible && r.orgs.length > 0).map((r) => ({ key: r.key, label: r.label, orgCount: r.orgs.length }));
|
|
112
|
+
}
|
|
113
|
+
async function getRegion(key, deps = defaultCfInfoDeps) {
|
|
114
|
+
const view = await deps.readRegionView(key);
|
|
115
|
+
return view?.region;
|
|
116
|
+
}
|
|
117
|
+
function findOrg(region, orgName) {
|
|
118
|
+
return region.orgs.find((o) => o.name === orgName);
|
|
119
|
+
}
|
|
120
|
+
function findSpace(org, spaceName) {
|
|
121
|
+
return org.spaces.find((s) => s.name === spaceName);
|
|
122
|
+
}
|
|
123
|
+
function findApp(space, appName) {
|
|
124
|
+
return space.apps.find((a) => a.name === appName);
|
|
125
|
+
}
|
|
126
|
+
async function resolveRef(ref, deps = defaultCfInfoDeps) {
|
|
127
|
+
const region = await getRegion(ref.region, deps);
|
|
128
|
+
if (!region) {
|
|
129
|
+
return void 0;
|
|
130
|
+
}
|
|
131
|
+
const org = findOrg(region, ref.org);
|
|
132
|
+
if (!org) {
|
|
133
|
+
return void 0;
|
|
134
|
+
}
|
|
135
|
+
const space = findSpace(org, ref.space);
|
|
136
|
+
if (!space) {
|
|
137
|
+
return void 0;
|
|
138
|
+
}
|
|
139
|
+
const app = findApp(space, ref.app);
|
|
140
|
+
if (!app) {
|
|
141
|
+
return void 0;
|
|
142
|
+
}
|
|
143
|
+
return { region, org, space, app };
|
|
144
|
+
}
|
|
70
145
|
|
|
71
146
|
// src/cf-meta.ts
|
|
72
147
|
import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
|
|
@@ -240,12 +315,231 @@ async function writeCfMetaToFile(path, ref, baseUrl) {
|
|
|
240
315
|
return changed;
|
|
241
316
|
}
|
|
242
317
|
|
|
318
|
+
// src/setup-app.ts
|
|
319
|
+
var COMMON_ENVIRONMENTS = ["local", "dev", "staging", "prod"];
|
|
320
|
+
var ENV_NAME_PATTERN = /^[A-Za-z0-9._-]+$/;
|
|
321
|
+
function assertValidEnvName(name) {
|
|
322
|
+
if (!ENV_NAME_PATTERN.test(name)) {
|
|
323
|
+
throw new Error(
|
|
324
|
+
`Invalid environment name '${name}': only letters, digits, dot, underscore, and dash are allowed.`
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
function emptyEnvContent(envName, ref) {
|
|
329
|
+
const lines = [
|
|
330
|
+
"vars {",
|
|
331
|
+
` __cf_region: ${ref.region}`,
|
|
332
|
+
` __cf_org: ${ref.org}`,
|
|
333
|
+
` __cf_space: ${ref.space}`,
|
|
334
|
+
` __cf_app: ${ref.app}`,
|
|
335
|
+
` environment: ${envName}`,
|
|
336
|
+
" baseUrl: ",
|
|
337
|
+
"}",
|
|
338
|
+
""
|
|
339
|
+
];
|
|
340
|
+
return lines.join("\n");
|
|
341
|
+
}
|
|
342
|
+
async function ensureEnvFile(appPath, envName, ref) {
|
|
343
|
+
const envDir = join2(appPath, ENVIRONMENTS_DIR);
|
|
344
|
+
await mkdir2(envDir, { recursive: true });
|
|
345
|
+
const filePath = join2(envDir, `${envName}.bru`);
|
|
346
|
+
try {
|
|
347
|
+
await writeFile3(filePath, emptyEnvContent(envName, ref), { encoding: "utf8", flag: "wx" });
|
|
348
|
+
} catch (err) {
|
|
349
|
+
if (err.code !== "EEXIST") {
|
|
350
|
+
throw err;
|
|
351
|
+
}
|
|
352
|
+
await writeCfMetaToFile(filePath, ref);
|
|
353
|
+
}
|
|
354
|
+
return filePath;
|
|
355
|
+
}
|
|
356
|
+
function pickRegion(regions) {
|
|
357
|
+
return regions.map((r) => ({ value: r.key, name: `${r.key} \u2014 ${r.label} (${r.orgCount.toString()} org${r.orgCount === 1 ? "" : "s"})` }));
|
|
358
|
+
}
|
|
359
|
+
function pickOrg(region) {
|
|
360
|
+
return region.orgs.map((o) => ({ value: o.name, name: `${o.name} (${o.spaces.length.toString()} space${o.spaces.length === 1 ? "" : "s"})` }));
|
|
361
|
+
}
|
|
362
|
+
function pickSpace(org) {
|
|
363
|
+
return org.spaces.map((s) => ({ value: s.name, name: `${s.name} (${s.apps.length.toString()} app${s.apps.length === 1 ? "" : "s"})` }));
|
|
364
|
+
}
|
|
365
|
+
function pickApp(space) {
|
|
366
|
+
return space.apps.map((a) => ({ value: a.name, name: a.name }));
|
|
367
|
+
}
|
|
368
|
+
async function setupApp(options) {
|
|
369
|
+
const deps = options.deps ?? defaultCfInfoDeps;
|
|
370
|
+
const log = options.log ?? (() => void 0);
|
|
371
|
+
const regions = await listRegionsWithContent(deps);
|
|
372
|
+
if (regions.length === 0) {
|
|
373
|
+
throw new Error(
|
|
374
|
+
"No CF regions with orgs are cached. Run `cf-sync sync` first, or pass SAP_EMAIL/SAP_PASSWORD to refresh."
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
const regionKey = await options.prompts.selectRegion(pickRegion(regions));
|
|
378
|
+
const regionView = await deps.readRegionView(regionKey);
|
|
379
|
+
if (!regionView) {
|
|
380
|
+
throw new Error(`Region ${regionKey} is not cached. Run \`cf-sync sync\` or \`cf-sync region ${regionKey}\`.`);
|
|
381
|
+
}
|
|
382
|
+
const region = regionView.region;
|
|
383
|
+
if (region.orgs.length === 0) {
|
|
384
|
+
throw new Error(`Region ${regionKey} has no accessible orgs.`);
|
|
385
|
+
}
|
|
386
|
+
const orgName = await options.prompts.selectOrg(pickOrg(region));
|
|
387
|
+
const org = region.orgs.find((o) => o.name === orgName);
|
|
388
|
+
if (!org) {
|
|
389
|
+
throw new Error(`Org ${orgName} not found in region ${regionKey}`);
|
|
390
|
+
}
|
|
391
|
+
if (org.spaces.length === 0) {
|
|
392
|
+
throw new Error(`Org ${orgName} has no spaces.`);
|
|
393
|
+
}
|
|
394
|
+
const spaceName = await options.prompts.selectSpace(pickSpace(org));
|
|
395
|
+
const space = org.spaces.find((s) => s.name === spaceName);
|
|
396
|
+
if (!space) {
|
|
397
|
+
throw new Error(`Space ${spaceName} not found in org ${orgName}`);
|
|
398
|
+
}
|
|
399
|
+
if (space.apps.length === 0) {
|
|
400
|
+
throw new Error(`Space ${spaceName} has no apps.`);
|
|
401
|
+
}
|
|
402
|
+
const appName = await options.prompts.selectApp(pickApp(space));
|
|
403
|
+
const ref = { region: regionKey, org: orgName, space: spaceName, app: appName };
|
|
404
|
+
const appPath = join2(
|
|
405
|
+
options.root,
|
|
406
|
+
regionFolderName(regionKey),
|
|
407
|
+
orgFolderName(orgName),
|
|
408
|
+
spaceFolderName(spaceName),
|
|
409
|
+
appName
|
|
410
|
+
);
|
|
411
|
+
const confirmed = await options.prompts.confirmCreate(appPath);
|
|
412
|
+
if (!confirmed) {
|
|
413
|
+
return { ref, appPath, environments: [], created: false };
|
|
414
|
+
}
|
|
415
|
+
await mkdir2(appPath, { recursive: true });
|
|
416
|
+
const existingEnvs = await listExistingEnvs(appPath);
|
|
417
|
+
const common = [...COMMON_ENVIRONMENTS];
|
|
418
|
+
const selected = await options.prompts.selectEnvironments({ common, existing: existingEnvs });
|
|
419
|
+
const merged = [];
|
|
420
|
+
for (const name of selected) {
|
|
421
|
+
const trimmed = name.trim();
|
|
422
|
+
if (trimmed.length === 0 || merged.includes(trimmed)) {
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
assertValidEnvName(trimmed);
|
|
426
|
+
merged.push(trimmed);
|
|
427
|
+
}
|
|
428
|
+
if (merged.length === 0) {
|
|
429
|
+
throw new Error("At least one environment is required.");
|
|
430
|
+
}
|
|
431
|
+
const created = [];
|
|
432
|
+
for (const envName of merged) {
|
|
433
|
+
const path = await ensureEnvFile(appPath, envName, ref);
|
|
434
|
+
created.push(path);
|
|
435
|
+
log(`\u2022 ${path}`);
|
|
436
|
+
}
|
|
437
|
+
return { ref, appPath, environments: created, created: true };
|
|
438
|
+
}
|
|
439
|
+
async function listExistingEnvs(appPath) {
|
|
440
|
+
try {
|
|
441
|
+
const entries = await readdir(join2(appPath, ENVIRONMENTS_DIR), { withFileTypes: true });
|
|
442
|
+
return entries.filter((e) => e.isFile() && e.name.endsWith(".bru")).map((e) => e.name.replace(/\.bru$/, ""));
|
|
443
|
+
} catch {
|
|
444
|
+
return [];
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// src/environment-prompt.ts
|
|
449
|
+
var ADD_CUSTOM_ENVIRONMENT = "__saptools_add_custom_environment__";
|
|
450
|
+
function uniqueNames(names) {
|
|
451
|
+
const merged = [];
|
|
452
|
+
for (const name of names) {
|
|
453
|
+
if (!merged.includes(name)) {
|
|
454
|
+
merged.push(name);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return merged;
|
|
458
|
+
}
|
|
459
|
+
function validateEnvironmentSelection(choices) {
|
|
460
|
+
const selected = choices.map((choice) => choice.value);
|
|
461
|
+
const hasEnvironment = selected.some((value) => value !== ADD_CUSTOM_ENVIRONMENT);
|
|
462
|
+
if (hasEnvironment || selected.includes(ADD_CUSTOM_ENVIRONMENT)) {
|
|
463
|
+
return true;
|
|
464
|
+
}
|
|
465
|
+
return 'Select at least one environment, or choose "Add custom environment".';
|
|
466
|
+
}
|
|
467
|
+
function buildEnvironmentChoices(names, selected) {
|
|
468
|
+
return [
|
|
469
|
+
...names.map((name) => ({
|
|
470
|
+
value: name,
|
|
471
|
+
name,
|
|
472
|
+
checked: selected.has(name)
|
|
473
|
+
})),
|
|
474
|
+
new Separator(),
|
|
475
|
+
{
|
|
476
|
+
value: ADD_CUSTOM_ENVIRONMENT,
|
|
477
|
+
name: "Add custom environment",
|
|
478
|
+
description: "Create another environment name and return to this menu"
|
|
479
|
+
}
|
|
480
|
+
];
|
|
481
|
+
}
|
|
482
|
+
function validateCustomEnvironmentName(value) {
|
|
483
|
+
const trimmed = value.trim();
|
|
484
|
+
if (trimmed.length === 0) {
|
|
485
|
+
return true;
|
|
486
|
+
}
|
|
487
|
+
try {
|
|
488
|
+
assertValidEnvName(trimmed);
|
|
489
|
+
return true;
|
|
490
|
+
} catch (err) {
|
|
491
|
+
return err instanceof Error ? err.message : String(err);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
async function promptForEnvironments(opts, deps = {}) {
|
|
495
|
+
const checkboxPrompt = deps.checkboxPrompt ?? checkbox;
|
|
496
|
+
const inputPrompt = deps.inputPrompt ?? input;
|
|
497
|
+
const selected = new Set(opts.existing);
|
|
498
|
+
const customNames = [];
|
|
499
|
+
for (; ; ) {
|
|
500
|
+
const names = uniqueNames([...opts.common, ...opts.existing, ...customNames]);
|
|
501
|
+
const answers = await checkboxPrompt({
|
|
502
|
+
message: "Environments to create (space to toggle, enter to continue)",
|
|
503
|
+
choices: buildEnvironmentChoices(names, selected),
|
|
504
|
+
validate: validateEnvironmentSelection
|
|
505
|
+
});
|
|
506
|
+
selected.clear();
|
|
507
|
+
for (const name of answers) {
|
|
508
|
+
if (name !== ADD_CUSTOM_ENVIRONMENT) {
|
|
509
|
+
selected.add(name);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
if (!answers.includes(ADD_CUSTOM_ENVIRONMENT)) {
|
|
513
|
+
return [...selected];
|
|
514
|
+
}
|
|
515
|
+
const custom = (await inputPrompt({
|
|
516
|
+
message: "Custom environment name (leave empty to go back)",
|
|
517
|
+
default: "",
|
|
518
|
+
validate: validateCustomEnvironmentName
|
|
519
|
+
})).trim();
|
|
520
|
+
if (custom.length === 0) {
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
if (!customNames.includes(custom) && !names.includes(custom)) {
|
|
524
|
+
customNames.push(custom);
|
|
525
|
+
}
|
|
526
|
+
selected.add(custom);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// src/run.ts
|
|
531
|
+
import { spawn } from "child_process";
|
|
532
|
+
import { readFile as readFile4, stat } from "fs/promises";
|
|
533
|
+
import { createRequire } from "module";
|
|
534
|
+
import { delimiter, dirname as dirname2, isAbsolute, join as join4, relative, resolve, sep } from "path";
|
|
535
|
+
import { getTokenCached as getTokenCachedApi } from "@saptools/cf-xsuaa";
|
|
536
|
+
|
|
243
537
|
// src/folder-scan.ts
|
|
244
|
-
import { readdir, readFile as readFile3 } from "fs/promises";
|
|
245
|
-
import { join as
|
|
538
|
+
import { readdir as readdir2, readFile as readFile3 } from "fs/promises";
|
|
539
|
+
import { join as join3 } from "path";
|
|
246
540
|
async function safeReaddir(path) {
|
|
247
541
|
try {
|
|
248
|
-
const entries = await
|
|
542
|
+
const entries = await readdir2(path, { withFileTypes: true });
|
|
249
543
|
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
250
544
|
} catch {
|
|
251
545
|
return [];
|
|
@@ -253,7 +547,7 @@ async function safeReaddir(path) {
|
|
|
253
547
|
}
|
|
254
548
|
async function listFiles(path) {
|
|
255
549
|
try {
|
|
256
|
-
const entries = await
|
|
550
|
+
const entries = await readdir2(path, { withFileTypes: true });
|
|
257
551
|
return entries.filter((e) => e.isFile()).map((e) => e.name);
|
|
258
552
|
} catch {
|
|
259
553
|
return [];
|
|
@@ -271,17 +565,17 @@ async function loadEnvFile(path, name) {
|
|
|
271
565
|
};
|
|
272
566
|
}
|
|
273
567
|
async function scanAppEnvironments(appPath) {
|
|
274
|
-
const envDir =
|
|
568
|
+
const envDir = join3(appPath, ENVIRONMENTS_DIR);
|
|
275
569
|
const files = await listFiles(envDir);
|
|
276
570
|
const bruFiles = files.filter((f) => f.endsWith(".bru"));
|
|
277
571
|
const loaded = [];
|
|
278
572
|
for (const file of bruFiles) {
|
|
279
|
-
loaded.push(await loadEnvFile(
|
|
573
|
+
loaded.push(await loadEnvFile(join3(envDir, file), file));
|
|
280
574
|
}
|
|
281
575
|
return loaded;
|
|
282
576
|
}
|
|
283
577
|
async function scanApp(spacePath, name) {
|
|
284
|
-
const appPath =
|
|
578
|
+
const appPath = join3(spacePath, name);
|
|
285
579
|
const environments = await scanAppEnvironments(appPath);
|
|
286
580
|
return { path: appPath, name, environments };
|
|
287
581
|
}
|
|
@@ -290,7 +584,7 @@ async function scanSpace(orgPath, dirName) {
|
|
|
290
584
|
if (name === void 0) {
|
|
291
585
|
return void 0;
|
|
292
586
|
}
|
|
293
|
-
const spacePath =
|
|
587
|
+
const spacePath = join3(orgPath, dirName);
|
|
294
588
|
const appDirs = await safeReaddir(spacePath);
|
|
295
589
|
const apps = [];
|
|
296
590
|
for (const appDir of appDirs) {
|
|
@@ -303,7 +597,7 @@ async function scanOrg(regionPath, dirName) {
|
|
|
303
597
|
if (name === void 0) {
|
|
304
598
|
return void 0;
|
|
305
599
|
}
|
|
306
|
-
const orgPath =
|
|
600
|
+
const orgPath = join3(regionPath, dirName);
|
|
307
601
|
const spaceDirs = await safeReaddir(orgPath);
|
|
308
602
|
const spaces = [];
|
|
309
603
|
for (const spaceDir of spaceDirs) {
|
|
@@ -319,7 +613,7 @@ async function scanRegion(root, dirName) {
|
|
|
319
613
|
if (key === void 0) {
|
|
320
614
|
return void 0;
|
|
321
615
|
}
|
|
322
|
-
const regionPath =
|
|
616
|
+
const regionPath = join3(root, dirName);
|
|
323
617
|
const orgDirs = await safeReaddir(regionPath);
|
|
324
618
|
const orgs = [];
|
|
325
619
|
for (const orgDir of orgDirs) {
|
|
@@ -377,7 +671,7 @@ async function findCommandOnPath(command, env) {
|
|
|
377
671
|
const candidates = pathCandidates(command, env);
|
|
378
672
|
for (const entry of pathEntries(env)) {
|
|
379
673
|
for (const candidate of candidates) {
|
|
380
|
-
const fullPath =
|
|
674
|
+
const fullPath = join4(entry, candidate);
|
|
381
675
|
if (await exists(fullPath)) {
|
|
382
676
|
return fullPath;
|
|
383
677
|
}
|
|
@@ -472,7 +766,7 @@ async function resolveTarget(root, target) {
|
|
|
472
766
|
throw new Error(`Target not found: ${target}`);
|
|
473
767
|
}
|
|
474
768
|
const { region, org, space, app, filePath } = shorthand;
|
|
475
|
-
const appDir =
|
|
769
|
+
const appDir = join4(
|
|
476
770
|
root,
|
|
477
771
|
regionFolderName(region),
|
|
478
772
|
orgFolderName(org),
|
|
@@ -482,7 +776,7 @@ async function resolveTarget(root, target) {
|
|
|
482
776
|
if (!filePath) {
|
|
483
777
|
return { filePath: appDir, shorthand };
|
|
484
778
|
}
|
|
485
|
-
const candidate =
|
|
779
|
+
const candidate = join4(appDir, filePath);
|
|
486
780
|
if (await exists(candidate)) {
|
|
487
781
|
return { filePath: candidate, shorthand };
|
|
488
782
|
}
|
|
@@ -494,7 +788,7 @@ async function resolveTarget(root, target) {
|
|
|
494
788
|
}
|
|
495
789
|
async function chooseEnvironmentFile(appDir, environment) {
|
|
496
790
|
if (environment) {
|
|
497
|
-
const envFile =
|
|
791
|
+
const envFile = join4(appDir, ENVIRONMENTS_DIR, `${environment}.bru`);
|
|
498
792
|
if (!await exists(envFile)) {
|
|
499
793
|
throw new Error(`Environment file not found: ${envFile}`);
|
|
500
794
|
}
|
|
@@ -526,7 +820,7 @@ function findAppDirFromFile(filePath, root) {
|
|
|
526
820
|
if (!regionDir || !orgDir || !spaceDir || !appDir) {
|
|
527
821
|
throw new Error(`File is not inside a CF-structured bruno collection: ${filePath}`);
|
|
528
822
|
}
|
|
529
|
-
return
|
|
823
|
+
return join4(root, regionDir, orgDir, spaceDir, appDir);
|
|
530
824
|
}
|
|
531
825
|
async function buildRunPlan(options) {
|
|
532
826
|
const { filePath } = await resolveTarget(options.root, options.target);
|
|
@@ -576,216 +870,6 @@ async function runBruno(options) {
|
|
|
576
870
|
return { ...plan, ...result };
|
|
577
871
|
}
|
|
578
872
|
|
|
579
|
-
// src/setup-app.ts
|
|
580
|
-
import { mkdir as mkdir2, readdir as readdir2, writeFile as writeFile3 } from "fs/promises";
|
|
581
|
-
import { join as join4 } from "path";
|
|
582
|
-
|
|
583
|
-
// src/cf-info.ts
|
|
584
|
-
import {
|
|
585
|
-
getRegionView as getRegionViewApi,
|
|
586
|
-
readRegionsView,
|
|
587
|
-
readRegionView,
|
|
588
|
-
readStructureView,
|
|
589
|
-
REGION_KEYS
|
|
590
|
-
} from "@saptools/cf-sync";
|
|
591
|
-
var defaultCfInfoDeps = {
|
|
592
|
-
readStructureView,
|
|
593
|
-
readRegionsView,
|
|
594
|
-
readRegionView,
|
|
595
|
-
getRegionView: getRegionViewApi
|
|
596
|
-
};
|
|
597
|
-
function isValidRegionKey(value) {
|
|
598
|
-
return REGION_KEYS.includes(value);
|
|
599
|
-
}
|
|
600
|
-
async function getStructureSnapshot(deps = defaultCfInfoDeps) {
|
|
601
|
-
const view = await deps.readStructureView();
|
|
602
|
-
if (!view) {
|
|
603
|
-
return {
|
|
604
|
-
source: "empty",
|
|
605
|
-
structure: void 0,
|
|
606
|
-
stale: true,
|
|
607
|
-
message: "No CF structure cached. Run `cf-sync sync` first."
|
|
608
|
-
};
|
|
609
|
-
}
|
|
610
|
-
const stale = view.source === "runtime" && view.metadata?.status === "running";
|
|
611
|
-
return {
|
|
612
|
-
source: view.source,
|
|
613
|
-
structure: view.structure,
|
|
614
|
-
stale,
|
|
615
|
-
message: stale ? "A CF sync is still running \u2014 showing partial data." : void 0
|
|
616
|
-
};
|
|
617
|
-
}
|
|
618
|
-
async function listRegionsWithContent(deps = defaultCfInfoDeps) {
|
|
619
|
-
const snapshot = await getStructureSnapshot(deps);
|
|
620
|
-
if (!snapshot.structure) {
|
|
621
|
-
return [];
|
|
622
|
-
}
|
|
623
|
-
return snapshot.structure.regions.filter((r) => r.accessible && r.orgs.length > 0).map((r) => ({ key: r.key, label: r.label, orgCount: r.orgs.length }));
|
|
624
|
-
}
|
|
625
|
-
async function getRegion(key, deps = defaultCfInfoDeps) {
|
|
626
|
-
const view = await deps.readRegionView(key);
|
|
627
|
-
return view?.region;
|
|
628
|
-
}
|
|
629
|
-
function findOrg(region, orgName) {
|
|
630
|
-
return region.orgs.find((o) => o.name === orgName);
|
|
631
|
-
}
|
|
632
|
-
function findSpace(org, spaceName) {
|
|
633
|
-
return org.spaces.find((s) => s.name === spaceName);
|
|
634
|
-
}
|
|
635
|
-
function findApp(space, appName) {
|
|
636
|
-
return space.apps.find((a) => a.name === appName);
|
|
637
|
-
}
|
|
638
|
-
async function resolveRef(ref, deps = defaultCfInfoDeps) {
|
|
639
|
-
const region = await getRegion(ref.region, deps);
|
|
640
|
-
if (!region) {
|
|
641
|
-
return void 0;
|
|
642
|
-
}
|
|
643
|
-
const org = findOrg(region, ref.org);
|
|
644
|
-
if (!org) {
|
|
645
|
-
return void 0;
|
|
646
|
-
}
|
|
647
|
-
const space = findSpace(org, ref.space);
|
|
648
|
-
if (!space) {
|
|
649
|
-
return void 0;
|
|
650
|
-
}
|
|
651
|
-
const app = findApp(space, ref.app);
|
|
652
|
-
if (!app) {
|
|
653
|
-
return void 0;
|
|
654
|
-
}
|
|
655
|
-
return { region, org, space, app };
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
// src/setup-app.ts
|
|
659
|
-
var COMMON_ENVIRONMENTS = ["local", "dev", "staging", "prod"];
|
|
660
|
-
var ENV_NAME_PATTERN = /^[A-Za-z0-9._-]+$/;
|
|
661
|
-
function assertValidEnvName(name) {
|
|
662
|
-
if (!ENV_NAME_PATTERN.test(name)) {
|
|
663
|
-
throw new Error(
|
|
664
|
-
`Invalid environment name '${name}': only letters, digits, dot, underscore, and dash are allowed.`
|
|
665
|
-
);
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
function emptyEnvContent(envName, ref) {
|
|
669
|
-
const lines = [
|
|
670
|
-
"vars {",
|
|
671
|
-
` __cf_region: ${ref.region}`,
|
|
672
|
-
` __cf_org: ${ref.org}`,
|
|
673
|
-
` __cf_space: ${ref.space}`,
|
|
674
|
-
` __cf_app: ${ref.app}`,
|
|
675
|
-
` environment: ${envName}`,
|
|
676
|
-
" baseUrl: ",
|
|
677
|
-
"}",
|
|
678
|
-
""
|
|
679
|
-
];
|
|
680
|
-
return lines.join("\n");
|
|
681
|
-
}
|
|
682
|
-
async function ensureEnvFile(appPath, envName, ref) {
|
|
683
|
-
const envDir = join4(appPath, ENVIRONMENTS_DIR);
|
|
684
|
-
await mkdir2(envDir, { recursive: true });
|
|
685
|
-
const filePath = join4(envDir, `${envName}.bru`);
|
|
686
|
-
try {
|
|
687
|
-
await writeFile3(filePath, emptyEnvContent(envName, ref), { encoding: "utf8", flag: "wx" });
|
|
688
|
-
} catch (err) {
|
|
689
|
-
if (err.code !== "EEXIST") {
|
|
690
|
-
throw err;
|
|
691
|
-
}
|
|
692
|
-
await writeCfMetaToFile(filePath, ref);
|
|
693
|
-
}
|
|
694
|
-
return filePath;
|
|
695
|
-
}
|
|
696
|
-
function pickRegion(regions) {
|
|
697
|
-
return regions.map((r) => ({ value: r.key, name: `${r.key} \u2014 ${r.label} (${r.orgCount.toString()} org${r.orgCount === 1 ? "" : "s"})` }));
|
|
698
|
-
}
|
|
699
|
-
function pickOrg(region) {
|
|
700
|
-
return region.orgs.map((o) => ({ value: o.name, name: `${o.name} (${o.spaces.length.toString()} space${o.spaces.length === 1 ? "" : "s"})` }));
|
|
701
|
-
}
|
|
702
|
-
function pickSpace(org) {
|
|
703
|
-
return org.spaces.map((s) => ({ value: s.name, name: `${s.name} (${s.apps.length.toString()} app${s.apps.length === 1 ? "" : "s"})` }));
|
|
704
|
-
}
|
|
705
|
-
function pickApp(space) {
|
|
706
|
-
return space.apps.map((a) => ({ value: a.name, name: a.name }));
|
|
707
|
-
}
|
|
708
|
-
async function setupApp(options) {
|
|
709
|
-
const deps = options.deps ?? defaultCfInfoDeps;
|
|
710
|
-
const log = options.log ?? (() => void 0);
|
|
711
|
-
const regions = await listRegionsWithContent(deps);
|
|
712
|
-
if (regions.length === 0) {
|
|
713
|
-
throw new Error(
|
|
714
|
-
"No CF regions with orgs are cached. Run `cf-sync sync` first, or pass SAP_EMAIL/SAP_PASSWORD to refresh."
|
|
715
|
-
);
|
|
716
|
-
}
|
|
717
|
-
const regionKey = await options.prompts.selectRegion(pickRegion(regions));
|
|
718
|
-
const regionView = await deps.readRegionView(regionKey);
|
|
719
|
-
if (!regionView) {
|
|
720
|
-
throw new Error(`Region ${regionKey} is not cached. Run \`cf-sync sync\` or \`cf-sync region ${regionKey}\`.`);
|
|
721
|
-
}
|
|
722
|
-
const region = regionView.region;
|
|
723
|
-
if (region.orgs.length === 0) {
|
|
724
|
-
throw new Error(`Region ${regionKey} has no accessible orgs.`);
|
|
725
|
-
}
|
|
726
|
-
const orgName = await options.prompts.selectOrg(pickOrg(region));
|
|
727
|
-
const org = region.orgs.find((o) => o.name === orgName);
|
|
728
|
-
if (!org) {
|
|
729
|
-
throw new Error(`Org ${orgName} not found in region ${regionKey}`);
|
|
730
|
-
}
|
|
731
|
-
if (org.spaces.length === 0) {
|
|
732
|
-
throw new Error(`Org ${orgName} has no spaces.`);
|
|
733
|
-
}
|
|
734
|
-
const spaceName = await options.prompts.selectSpace(pickSpace(org));
|
|
735
|
-
const space = org.spaces.find((s) => s.name === spaceName);
|
|
736
|
-
if (!space) {
|
|
737
|
-
throw new Error(`Space ${spaceName} not found in org ${orgName}`);
|
|
738
|
-
}
|
|
739
|
-
if (space.apps.length === 0) {
|
|
740
|
-
throw new Error(`Space ${spaceName} has no apps.`);
|
|
741
|
-
}
|
|
742
|
-
const appName = await options.prompts.selectApp(pickApp(space));
|
|
743
|
-
const ref = { region: regionKey, org: orgName, space: spaceName, app: appName };
|
|
744
|
-
const appPath = join4(
|
|
745
|
-
options.root,
|
|
746
|
-
regionFolderName(regionKey),
|
|
747
|
-
orgFolderName(orgName),
|
|
748
|
-
spaceFolderName(spaceName),
|
|
749
|
-
appName
|
|
750
|
-
);
|
|
751
|
-
const confirmed = await options.prompts.confirmCreate(appPath);
|
|
752
|
-
if (!confirmed) {
|
|
753
|
-
return { ref, appPath, environments: [], created: false };
|
|
754
|
-
}
|
|
755
|
-
await mkdir2(appPath, { recursive: true });
|
|
756
|
-
const existingEnvs = await listExistingEnvs(appPath);
|
|
757
|
-
const common = [...COMMON_ENVIRONMENTS];
|
|
758
|
-
const selected = await options.prompts.selectEnvironments({ common, existing: existingEnvs });
|
|
759
|
-
const custom = await options.prompts.inputCustomEnvName();
|
|
760
|
-
const merged = [];
|
|
761
|
-
for (const name of [...selected, ...custom ? [custom] : []]) {
|
|
762
|
-
const trimmed = name.trim();
|
|
763
|
-
if (trimmed.length === 0 || merged.includes(trimmed)) {
|
|
764
|
-
continue;
|
|
765
|
-
}
|
|
766
|
-
assertValidEnvName(trimmed);
|
|
767
|
-
merged.push(trimmed);
|
|
768
|
-
}
|
|
769
|
-
if (merged.length === 0) {
|
|
770
|
-
throw new Error("At least one environment is required.");
|
|
771
|
-
}
|
|
772
|
-
const created = [];
|
|
773
|
-
for (const envName of merged) {
|
|
774
|
-
const path = await ensureEnvFile(appPath, envName, ref);
|
|
775
|
-
created.push(path);
|
|
776
|
-
log(`\u2022 ${path}`);
|
|
777
|
-
}
|
|
778
|
-
return { ref, appPath, environments: created, created: true };
|
|
779
|
-
}
|
|
780
|
-
async function listExistingEnvs(appPath) {
|
|
781
|
-
try {
|
|
782
|
-
const entries = await readdir2(join4(appPath, ENVIRONMENTS_DIR), { withFileTypes: true });
|
|
783
|
-
return entries.filter((e) => e.isFile() && e.name.endsWith(".bru")).map((e) => e.name.replace(/\.bru$/, ""));
|
|
784
|
-
} catch {
|
|
785
|
-
return [];
|
|
786
|
-
}
|
|
787
|
-
}
|
|
788
|
-
|
|
789
873
|
// src/use.ts
|
|
790
874
|
function parseContextShorthand(shorthand) {
|
|
791
875
|
const segs = shorthand.split("/").filter((s) => s.length > 0);
|
|
@@ -820,9 +904,15 @@ async function useContext(options) {
|
|
|
820
904
|
}
|
|
821
905
|
|
|
822
906
|
// src/cli.ts
|
|
823
|
-
function
|
|
824
|
-
if (
|
|
825
|
-
return
|
|
907
|
+
function resolveCollectionDir(explicitCollection, explicitRoot) {
|
|
908
|
+
if (explicitCollection) {
|
|
909
|
+
return explicitCollection;
|
|
910
|
+
}
|
|
911
|
+
if (explicitRoot) {
|
|
912
|
+
return explicitRoot;
|
|
913
|
+
}
|
|
914
|
+
if (process2.env["SAPTOOLS_BRUNO_COLLECTION"]) {
|
|
915
|
+
return process2.env["SAPTOOLS_BRUNO_COLLECTION"];
|
|
826
916
|
}
|
|
827
917
|
if (process2.env["SAPTOOLS_BRUNO_ROOT"]) {
|
|
828
918
|
return process2.env["SAPTOOLS_BRUNO_ROOT"];
|
|
@@ -831,50 +921,21 @@ function resolveRoot(explicit) {
|
|
|
831
921
|
}
|
|
832
922
|
async function main(argv) {
|
|
833
923
|
const program = new Command();
|
|
834
|
-
program.name("saptools-bruno").description("Smart runner for Bruno with CF-aware env metadata and automatic token injection").
|
|
924
|
+
program.name("saptools-bruno").description("Smart runner for Bruno with CF-aware env metadata and automatic token injection").addOption(new Option("--collection <dir>", "Bruno collection directory (default: SAPTOOLS_BRUNO_COLLECTION or cwd)")).addOption(new Option("--root <dir>", "Legacy alias for --collection").hideHelp());
|
|
835
925
|
program.command("setup-app").description("Interactively scaffold a bruno app folder and seed __cf_* variables").action(async () => {
|
|
836
|
-
const
|
|
926
|
+
const collectionDir = resolveCollectionDir(
|
|
927
|
+
program.opts().collection,
|
|
928
|
+
program.opts().root
|
|
929
|
+
);
|
|
837
930
|
const result = await setupApp({
|
|
838
|
-
root,
|
|
931
|
+
root: collectionDir,
|
|
839
932
|
prompts: {
|
|
840
933
|
selectRegion: async (choices) => await select({ message: "Select region", choices: [...choices] }),
|
|
841
934
|
selectOrg: async (choices) => await select({ message: "Select org", choices: [...choices] }),
|
|
842
935
|
selectSpace: async (choices) => await select({ message: "Select space", choices: [...choices] }),
|
|
843
936
|
selectApp: async (choices) => await select({ message: "Select app", choices: [...choices] }),
|
|
844
937
|
confirmCreate: async (path) => await confirm({ message: `Create ${path}?`, default: true }),
|
|
845
|
-
selectEnvironments: async (
|
|
846
|
-
const seen = /* @__PURE__ */ new Set();
|
|
847
|
-
const all = [...common, ...existing].filter((name) => {
|
|
848
|
-
if (seen.has(name)) {
|
|
849
|
-
return false;
|
|
850
|
-
}
|
|
851
|
-
seen.add(name);
|
|
852
|
-
return true;
|
|
853
|
-
});
|
|
854
|
-
return await checkbox({
|
|
855
|
-
message: "Environments to create (space to toggle, enter to confirm)",
|
|
856
|
-
choices: all.map((name) => ({
|
|
857
|
-
name,
|
|
858
|
-
value: name,
|
|
859
|
-
checked: existing.includes(name)
|
|
860
|
-
}))
|
|
861
|
-
});
|
|
862
|
-
},
|
|
863
|
-
inputCustomEnvName: async () => {
|
|
864
|
-
const raw = await input({
|
|
865
|
-
message: "Custom environment name (leave empty to skip)",
|
|
866
|
-
default: "",
|
|
867
|
-
validate: (v) => {
|
|
868
|
-
const t = v.trim();
|
|
869
|
-
if (t.length === 0) {
|
|
870
|
-
return true;
|
|
871
|
-
}
|
|
872
|
-
return /^[A-Za-z0-9._-]+$/.test(t) ? true : "Only letters, digits, dot, underscore, and dash are allowed.";
|
|
873
|
-
}
|
|
874
|
-
});
|
|
875
|
-
const trimmed = raw.trim();
|
|
876
|
-
return trimmed.length > 0 ? trimmed : null;
|
|
877
|
-
}
|
|
938
|
+
selectEnvironments: async (opts) => await promptForEnvironments(opts)
|
|
878
939
|
},
|
|
879
940
|
log: (msg) => {
|
|
880
941
|
process2.stdout.write(`${msg}
|
|
@@ -890,7 +951,10 @@ async function main(argv) {
|
|
|
890
951
|
});
|
|
891
952
|
program.command("run").description("Run a bruno request or folder, auto-injecting an XSUAA token").argument("[target]", "Shorthand path (region/org/space/app[/folder/file.bru]) or real path").option("-e, --env <name>", "Environment name (default: context or first)").action(
|
|
892
953
|
async (target, opts) => {
|
|
893
|
-
const
|
|
954
|
+
const collectionDir = resolveCollectionDir(
|
|
955
|
+
program.opts().collection,
|
|
956
|
+
program.opts().root
|
|
957
|
+
);
|
|
894
958
|
let effectiveTarget = target;
|
|
895
959
|
if (!effectiveTarget) {
|
|
896
960
|
const ctx = await readContext();
|
|
@@ -902,7 +966,7 @@ async function main(argv) {
|
|
|
902
966
|
effectiveTarget = `${ctx.region}/${ctx.org}/${ctx.space}/${ctx.app}`;
|
|
903
967
|
}
|
|
904
968
|
const result = await runBruno({
|
|
905
|
-
root,
|
|
969
|
+
root: collectionDir,
|
|
906
970
|
target: effectiveTarget,
|
|
907
971
|
...opts.env ? { environment: opts.env } : {},
|
|
908
972
|
log: (msg) => {
|