@saptools/bruno 0.3.1 → 0.3.3

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/cli.js CHANGED
@@ -1,202 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // src/cli.ts
3
+ // src/cli/main.ts
4
4
  import process2 from "process";
5
5
  import { confirm, select } from "@inquirer/prompts";
6
6
  import { Command, Option } from "commander";
7
7
 
8
- // src/app-search-prompt.ts
9
- import { search } from "@inquirer/prompts";
10
- var DEFAULT_PAGE_SIZE = 12;
11
- var NO_MATCHING_APP = "__saptools_no_matching_app__";
12
- function normalizeTerm(term) {
13
- return term?.trim().toLowerCase() ?? "";
14
- }
15
- function scoreChoice(choice, normalizedTerm) {
16
- const name = choice.name.toLowerCase();
17
- const value = choice.value.toLowerCase();
18
- if (name === normalizedTerm || value === normalizedTerm) {
19
- return 0;
20
- }
21
- if (name.startsWith(normalizedTerm) || value.startsWith(normalizedTerm)) {
22
- return 1;
23
- }
24
- if (name.includes(normalizedTerm) || value.includes(normalizedTerm)) {
25
- return 2;
26
- }
27
- return Number.POSITIVE_INFINITY;
28
- }
29
- function noMatchChoice(term) {
30
- const label = term?.trim() ?? "";
31
- return {
32
- value: NO_MATCHING_APP,
33
- name: `No apps match "${label}"`,
34
- disabled: "Type a different search term"
35
- };
36
- }
37
- function buildAppSearchChoices(choices, term) {
38
- const normalizedTerm = normalizeTerm(term);
39
- if (normalizedTerm.length === 0) {
40
- return [...choices];
41
- }
42
- const rankedMatches = choices.map((choice, index) => ({ choice, index, score: scoreChoice(choice, normalizedTerm) })).filter((item) => Number.isFinite(item.score)).sort((left, right) => left.score - right.score || left.index - right.index).map((item) => item.choice);
43
- if (rankedMatches.length > 0) {
44
- return rankedMatches;
45
- }
46
- return [noMatchChoice(term)];
47
- }
48
- async function promptForAppSelection(choices, deps = {}) {
49
- const searchPrompt = deps.searchPrompt ?? search;
50
- return await searchPrompt({
51
- message: "Select app",
52
- pageSize: DEFAULT_PAGE_SIZE,
53
- source: (term) => Promise.resolve(buildAppSearchChoices(choices, term)),
54
- validate: (value) => value === NO_MATCHING_APP ? "Select a real app." : true
55
- });
56
- }
57
-
58
- // src/context.ts
59
- import { mkdir, readFile, writeFile } from "fs/promises";
60
- import { dirname } from "path";
61
-
62
- // src/paths.ts
63
- import { homedir } from "os";
64
- import { join } from "path";
65
- var SAPTOOLS_DIR_NAME = ".saptools";
66
- var BRUNO_CONTEXT_FILENAME = "bruno-context.json";
67
- var REGION_FOLDER_PREFIX = "region__";
68
- var ORG_FOLDER_PREFIX = "org__";
69
- var SPACE_FOLDER_PREFIX = "space__";
70
- var ENVIRONMENTS_DIR = "environments";
71
- function saptoolsDir() {
72
- return join(homedir(), SAPTOOLS_DIR_NAME);
73
- }
74
- function brunoContextPath() {
75
- return join(saptoolsDir(), BRUNO_CONTEXT_FILENAME);
76
- }
77
- function regionFolderName(key) {
78
- return `${REGION_FOLDER_PREFIX}${key}`;
79
- }
80
- function orgFolderName(name) {
81
- return `${ORG_FOLDER_PREFIX}${name}`;
82
- }
83
- function spaceFolderName(name) {
84
- return `${SPACE_FOLDER_PREFIX}${name}`;
85
- }
86
- function parsePrefixedName(dirName, prefix) {
87
- if (!dirName.startsWith(prefix)) {
88
- return void 0;
89
- }
90
- return dirName.slice(prefix.length);
91
- }
92
-
93
- // src/context.ts
94
- async function readContext() {
95
- try {
96
- const raw = await readFile(brunoContextPath(), "utf8");
97
- return JSON.parse(raw);
98
- } catch (err) {
99
- if (err.code === "ENOENT") {
100
- return void 0;
101
- }
102
- throw err;
103
- }
104
- }
105
- async function writeContext(ctx) {
106
- const updated = { ...ctx, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
107
- const path = brunoContextPath();
108
- await mkdir(dirname(path), { recursive: true });
109
- await writeFile(path, `${JSON.stringify(updated, null, 2)}
110
- `, "utf8");
111
- return updated;
112
- }
113
-
114
- // src/environment-prompt.ts
115
- import { Separator, checkbox, input } from "@inquirer/prompts";
116
-
117
- // src/setup-app.ts
118
- import { mkdir as mkdir2, readdir, writeFile as writeFile3 } from "fs/promises";
119
- import { basename, join as join2 } from "path";
120
-
121
- // src/cf-info.ts
122
- import {
123
- getRegionView as getRegionViewApi,
124
- readRegionsView,
125
- readRegionView,
126
- readStructureView,
127
- REGION_KEYS
128
- } from "@saptools/cf-sync";
129
- var defaultCfInfoDeps = {
130
- readStructureView,
131
- readRegionsView,
132
- readRegionView,
133
- getRegionView: getRegionViewApi
134
- };
135
- function isValidRegionKey(value) {
136
- return REGION_KEYS.includes(value);
137
- }
138
- async function getStructureSnapshot(deps = defaultCfInfoDeps) {
139
- const view = await deps.readStructureView();
140
- if (!view) {
141
- return {
142
- source: "empty",
143
- structure: void 0,
144
- stale: true,
145
- message: "No CF structure cached. Run `cf-sync sync` first."
146
- };
147
- }
148
- const stale = view.source === "runtime" && view.metadata?.status === "running";
149
- return {
150
- source: view.source,
151
- structure: view.structure,
152
- stale,
153
- message: stale ? "A CF sync is still running \u2014 showing partial data." : void 0
154
- };
155
- }
156
- async function listRegionsWithContent(deps = defaultCfInfoDeps) {
157
- const snapshot = await getStructureSnapshot(deps);
158
- if (!snapshot.structure) {
159
- return [];
160
- }
161
- return snapshot.structure.regions.filter((r) => r.accessible && r.orgs.length > 0).map((r) => ({ key: r.key, label: r.label, orgCount: r.orgs.length }));
162
- }
163
- async function getRegion(key, deps = defaultCfInfoDeps) {
164
- const view = await deps.readRegionView(key);
165
- return view?.region;
166
- }
167
- function findOrg(region, orgName) {
168
- return region.orgs.find((o) => o.name === orgName);
169
- }
170
- function findSpace(org, spaceName) {
171
- return org.spaces.find((s) => s.name === spaceName);
172
- }
173
- function findApp(space, appName) {
174
- return space.apps.find((a) => a.name === appName);
175
- }
176
- async function resolveRef(ref, deps = defaultCfInfoDeps) {
177
- const region = await getRegion(ref.region, deps);
178
- if (!region) {
179
- return void 0;
180
- }
181
- const org = findOrg(region, ref.org);
182
- if (!org) {
183
- return void 0;
184
- }
185
- const space = findSpace(org, ref.space);
186
- if (!space) {
187
- return void 0;
188
- }
189
- const app = findApp(space, ref.app);
190
- if (!app) {
191
- return void 0;
192
- }
193
- return { region, org, space, app };
194
- }
195
-
196
- // src/cf-meta.ts
197
- import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
8
+ // src/commands/run.ts
9
+ import { spawn } from "child_process";
10
+ import { readFile as readFile3, stat, writeFile as writeFile2 } from "fs/promises";
11
+ import { createRequire } from "module";
12
+ import { delimiter, dirname, isAbsolute, join as join3, relative, resolve, sep } from "path";
13
+ import { getTokenCached as getTokenCachedApi } from "@saptools/cf-xsuaa";
198
14
 
199
- // src/bru-parser.ts
15
+ // src/bruno/parser.ts
200
16
  var HEADER_REGEX = /(^|\n)[^\S\n]*([a-zA-Z][a-zA-Z0-9:_-]*)[^\S\n]*([{[])/g;
201
17
  function findMatchingClose(raw, open, openIdx) {
202
18
  const close = open === "{" ? "}" : "]";
@@ -284,7 +100,7 @@ function parseBruEnvFile(raw) {
284
100
  return { vars: { entries }, secrets };
285
101
  }
286
102
 
287
- // src/bru-writer.ts
103
+ // src/bruno/writer.ts
288
104
  function formatVarsBlock(entries) {
289
105
  const lines = [];
290
106
  for (const [key, value] of entries) {
@@ -323,7 +139,8 @@ ${formatVarsBlock(existing)}
323
139
  return { content: `${before}${rebuilt}${after}`, changed: true };
324
140
  }
325
141
 
326
- // src/cf-meta.ts
142
+ // src/cf/meta.ts
143
+ import { readFile, writeFile } from "fs/promises";
327
144
  function readCfMetaFromVars(vars) {
328
145
  const region = vars.get("__cf_region");
329
146
  const org = vars.get("__cf_org");
@@ -351,287 +168,74 @@ function buildCfMetaUpdates(ref, baseUrl) {
351
168
  return updates;
352
169
  }
353
170
  async function readCfMetaFromFile(path) {
354
- const raw = await readFile2(path, "utf8");
171
+ const raw = await readFile(path, "utf8");
355
172
  const parsed = parseBruEnvFile(raw);
356
173
  return readCfMetaFromVars(parsed.vars.entries);
357
174
  }
358
175
  async function writeCfMetaToFile(path, ref, baseUrl) {
359
- const raw = await readFile2(path, "utf8");
176
+ const raw = await readFile(path, "utf8");
360
177
  const updates = buildCfMetaUpdates(ref, baseUrl);
361
178
  const { content, changed } = upsertVars(raw, updates);
362
179
  if (changed) {
363
- await writeFile2(path, content, "utf8");
180
+ await writeFile(path, content, "utf8");
364
181
  }
365
182
  return changed;
366
183
  }
367
184
 
368
- // src/setup-app.ts
369
- var COMMON_ENVIRONMENTS = ["local", "dev", "staging", "prod"];
370
- var BRUNO_COLLECTION_CONFIG_FILENAME = "bruno.json";
371
- var ENV_NAME_PATTERN = /^[A-Za-z0-9._-]+$/;
372
- function assertValidEnvName(name) {
373
- if (!ENV_NAME_PATTERN.test(name)) {
374
- throw new Error(
375
- `Invalid environment name '${name}': only letters, digits, dot, underscore, and dash are allowed.`
376
- );
377
- }
185
+ // src/collection/folder-scan.ts
186
+ import { readdir, readFile as readFile2 } from "fs/promises";
187
+ import { join as join2 } from "path";
188
+
189
+ // src/collection/paths.ts
190
+ import { homedir } from "os";
191
+ import { join } from "path";
192
+ var SAPTOOLS_DIR_NAME = ".saptools";
193
+ var BRUNO_CONTEXT_FILENAME = "bruno-context.json";
194
+ var REGION_FOLDER_PREFIX = "region__";
195
+ var ORG_FOLDER_PREFIX = "org__";
196
+ var SPACE_FOLDER_PREFIX = "space__";
197
+ var ENVIRONMENTS_DIR = "environments";
198
+ function saptoolsDir() {
199
+ return join(homedir(), SAPTOOLS_DIR_NAME);
378
200
  }
379
- function emptyEnvContent(envName, ref) {
380
- const lines = [
381
- "vars {",
382
- ` __cf_region: ${ref.region}`,
383
- ` __cf_org: ${ref.org}`,
384
- ` __cf_space: ${ref.space}`,
385
- ` __cf_app: ${ref.app}`,
386
- ` environment: ${envName}`,
387
- " baseUrl: ",
388
- "}",
389
- ""
390
- ];
391
- return lines.join("\n");
201
+ function brunoContextPath() {
202
+ return join(saptoolsDir(), BRUNO_CONTEXT_FILENAME);
392
203
  }
393
- function normalizeCollectionName(root) {
394
- const candidate = basename(root).replace(/^\.+/, "").trim();
395
- return candidate.length > 0 ? candidate : "bruno-collection";
204
+ function regionFolderName(key) {
205
+ return `${REGION_FOLDER_PREFIX}${key}`;
396
206
  }
397
- function defaultBrunoConfig(root) {
398
- return `${JSON.stringify(
399
- {
400
- version: "1",
401
- name: normalizeCollectionName(root),
402
- type: "collection",
403
- ignore: ["node_modules", ".git"]
404
- },
405
- null,
406
- 2
407
- )}
408
- `;
207
+ function orgFolderName(name) {
208
+ return `${ORG_FOLDER_PREFIX}${name}`;
409
209
  }
410
- async function ensureCollectionConfig(root) {
411
- const filePath = join2(root, BRUNO_COLLECTION_CONFIG_FILENAME);
210
+ function spaceFolderName(name) {
211
+ return `${SPACE_FOLDER_PREFIX}${name}`;
212
+ }
213
+ function parsePrefixedName(dirName, prefix) {
214
+ if (!dirName.startsWith(prefix)) {
215
+ return void 0;
216
+ }
217
+ return dirName.slice(prefix.length);
218
+ }
219
+
220
+ // src/collection/folder-scan.ts
221
+ async function safeReaddir(path) {
412
222
  try {
413
- await writeFile3(filePath, defaultBrunoConfig(root), { encoding: "utf8", flag: "wx" });
414
- } catch (err) {
415
- if (err.code !== "EEXIST") {
416
- throw err;
417
- }
223
+ const entries = await readdir(path, { withFileTypes: true });
224
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
225
+ } catch {
226
+ return [];
418
227
  }
419
228
  }
420
- async function ensureEnvFile(appPath, envName, ref) {
421
- const envDir = join2(appPath, ENVIRONMENTS_DIR);
422
- await mkdir2(envDir, { recursive: true });
423
- const filePath = join2(envDir, `${envName}.bru`);
229
+ async function listFiles(path) {
424
230
  try {
425
- await writeFile3(filePath, emptyEnvContent(envName, ref), { encoding: "utf8", flag: "wx" });
426
- } catch (err) {
427
- if (err.code !== "EEXIST") {
428
- throw err;
429
- }
430
- await writeCfMetaToFile(filePath, ref);
431
- }
432
- return filePath;
433
- }
434
- function pickRegion(regions) {
435
- return regions.map((r) => ({ value: r.key, name: `${r.key} \u2014 ${r.label} (${r.orgCount.toString()} org${r.orgCount === 1 ? "" : "s"})` }));
436
- }
437
- function pickOrg(region) {
438
- return region.orgs.map((o) => ({ value: o.name, name: `${o.name} (${o.spaces.length.toString()} space${o.spaces.length === 1 ? "" : "s"})` }));
439
- }
440
- function pickSpace(org) {
441
- return org.spaces.map((s) => ({ value: s.name, name: `${s.name} (${s.apps.length.toString()} app${s.apps.length === 1 ? "" : "s"})` }));
442
- }
443
- function pickApp(space) {
444
- return space.apps.map((a) => ({ value: a.name, name: a.name }));
445
- }
446
- async function setupApp(options) {
447
- const deps = options.deps ?? defaultCfInfoDeps;
448
- const log = options.log ?? (() => void 0);
449
- const regions = await listRegionsWithContent(deps);
450
- if (regions.length === 0) {
451
- throw new Error("No CF regions with orgs are cached. Run `cf-sync sync` first.");
452
- }
453
- const regionKey = await options.prompts.selectRegion(pickRegion(regions));
454
- const regionView = await deps.readRegionView(regionKey);
455
- if (!regionView) {
456
- throw new Error(`Region ${regionKey} is not cached. Run \`cf-sync region ${regionKey}\` or \`cf-sync sync\` first.`);
457
- }
458
- const region = regionView.region;
459
- if (region.orgs.length === 0) {
460
- throw new Error(`Region ${regionKey} has no accessible orgs.`);
461
- }
462
- const orgName = await options.prompts.selectOrg(pickOrg(region));
463
- const org = region.orgs.find((o) => o.name === orgName);
464
- if (!org) {
465
- throw new Error(`Org ${orgName} not found in region ${regionKey}`);
466
- }
467
- if (org.spaces.length === 0) {
468
- throw new Error(`Org ${orgName} has no spaces.`);
469
- }
470
- const spaceName = await options.prompts.selectSpace(pickSpace(org));
471
- const space = org.spaces.find((s) => s.name === spaceName);
472
- if (!space) {
473
- throw new Error(`Space ${spaceName} not found in org ${orgName}`);
474
- }
475
- if (space.apps.length === 0) {
476
- throw new Error(`Space ${spaceName} has no apps.`);
477
- }
478
- const appName = await options.prompts.selectApp(pickApp(space));
479
- const ref = { region: regionKey, org: orgName, space: spaceName, app: appName };
480
- const appPath = join2(
481
- options.root,
482
- regionFolderName(regionKey),
483
- orgFolderName(orgName),
484
- spaceFolderName(spaceName),
485
- appName
486
- );
487
- const confirmed = await options.prompts.confirmCreate(appPath);
488
- if (!confirmed) {
489
- return { ref, appPath, environments: [], created: false };
490
- }
491
- await mkdir2(appPath, { recursive: true });
492
- await ensureCollectionConfig(appPath);
493
- const existingEnvs = await listExistingEnvs(appPath);
494
- const common = [...COMMON_ENVIRONMENTS];
495
- const selected = await options.prompts.selectEnvironments({ common, existing: existingEnvs });
496
- const merged = [];
497
- for (const name of selected) {
498
- const trimmed = name.trim();
499
- if (trimmed.length === 0 || merged.includes(trimmed)) {
500
- continue;
501
- }
502
- assertValidEnvName(trimmed);
503
- merged.push(trimmed);
504
- }
505
- if (merged.length === 0) {
506
- throw new Error("At least one environment is required.");
507
- }
508
- const created = [];
509
- for (const envName of merged) {
510
- const path = await ensureEnvFile(appPath, envName, ref);
511
- created.push(path);
512
- log(`\u2022 ${path}`);
513
- }
514
- return { ref, appPath, environments: created, created: true };
515
- }
516
- async function listExistingEnvs(appPath) {
517
- try {
518
- const entries = await readdir(join2(appPath, ENVIRONMENTS_DIR), { withFileTypes: true });
519
- return entries.filter((e) => e.isFile() && e.name.endsWith(".bru")).map((e) => e.name.replace(/\.bru$/, ""));
520
- } catch {
521
- return [];
522
- }
523
- }
524
-
525
- // src/environment-prompt.ts
526
- var ADD_CUSTOM_ENVIRONMENT = "__saptools_add_custom_environment__";
527
- function uniqueNames(names) {
528
- const merged = [];
529
- for (const name of names) {
530
- if (!merged.includes(name)) {
531
- merged.push(name);
532
- }
533
- }
534
- return merged;
535
- }
536
- function validateEnvironmentSelection(choices) {
537
- const selected = choices.map((choice) => choice.value);
538
- const hasEnvironment = selected.some((value) => value !== ADD_CUSTOM_ENVIRONMENT);
539
- if (hasEnvironment || selected.includes(ADD_CUSTOM_ENVIRONMENT)) {
540
- return true;
541
- }
542
- return 'Select at least one environment, or choose "Add custom environment".';
543
- }
544
- function buildEnvironmentChoices(names, selected) {
545
- return [
546
- ...names.map((name) => ({
547
- value: name,
548
- name,
549
- checked: selected.has(name)
550
- })),
551
- new Separator(),
552
- {
553
- value: ADD_CUSTOM_ENVIRONMENT,
554
- name: "Add custom environment",
555
- description: "Create another environment name and return to this menu"
556
- }
557
- ];
558
- }
559
- function validateCustomEnvironmentName(value) {
560
- const trimmed = value.trim();
561
- if (trimmed.length === 0) {
562
- return true;
563
- }
564
- try {
565
- assertValidEnvName(trimmed);
566
- return true;
567
- } catch (err) {
568
- return err instanceof Error ? err.message : String(err);
569
- }
570
- }
571
- async function promptForEnvironments(opts, deps = {}) {
572
- const checkboxPrompt = deps.checkboxPrompt ?? checkbox;
573
- const inputPrompt = deps.inputPrompt ?? input;
574
- const selected = new Set(opts.existing);
575
- const customNames = [];
576
- for (; ; ) {
577
- const names = uniqueNames([...opts.common, ...opts.existing, ...customNames]);
578
- const answers = await checkboxPrompt({
579
- message: "Environments to create (space to toggle, enter to continue)",
580
- choices: buildEnvironmentChoices(names, selected),
581
- validate: validateEnvironmentSelection
582
- });
583
- selected.clear();
584
- for (const name of answers) {
585
- if (name !== ADD_CUSTOM_ENVIRONMENT) {
586
- selected.add(name);
587
- }
588
- }
589
- if (!answers.includes(ADD_CUSTOM_ENVIRONMENT)) {
590
- return [...selected];
591
- }
592
- const custom = (await inputPrompt({
593
- message: "Custom environment name (leave empty to go back)",
594
- default: "",
595
- validate: validateCustomEnvironmentName
596
- })).trim();
597
- if (custom.length === 0) {
598
- continue;
599
- }
600
- if (!customNames.includes(custom) && !names.includes(custom)) {
601
- customNames.push(custom);
602
- }
603
- selected.add(custom);
604
- }
605
- }
606
-
607
- // src/run.ts
608
- import { spawn } from "child_process";
609
- import { readFile as readFile4, stat, writeFile as writeFile4 } from "fs/promises";
610
- import { createRequire } from "module";
611
- import { delimiter, dirname as dirname2, isAbsolute, join as join4, relative, resolve, sep } from "path";
612
- import { getTokenCached as getTokenCachedApi } from "@saptools/cf-xsuaa";
613
-
614
- // src/folder-scan.ts
615
- import { readdir as readdir2, readFile as readFile3 } from "fs/promises";
616
- import { join as join3 } from "path";
617
- async function safeReaddir(path) {
618
- try {
619
- const entries = await readdir2(path, { withFileTypes: true });
620
- return entries.filter((e) => e.isDirectory()).map((e) => e.name);
621
- } catch {
622
- return [];
623
- }
624
- }
625
- async function listFiles(path) {
626
- try {
627
- const entries = await readdir2(path, { withFileTypes: true });
231
+ const entries = await readdir(path, { withFileTypes: true });
628
232
  return entries.filter((e) => e.isFile()).map((e) => e.name);
629
233
  } catch {
630
234
  return [];
631
235
  }
632
236
  }
633
237
  async function loadEnvFile(path, name) {
634
- const raw = await readFile3(path, "utf8");
238
+ const raw = await readFile2(path, "utf8");
635
239
  const parsed = parseBruEnvFile(raw);
636
240
  return {
637
241
  path,
@@ -642,17 +246,17 @@ async function loadEnvFile(path, name) {
642
246
  };
643
247
  }
644
248
  async function scanAppEnvironments(appPath) {
645
- const envDir = join3(appPath, ENVIRONMENTS_DIR);
249
+ const envDir = join2(appPath, ENVIRONMENTS_DIR);
646
250
  const files = await listFiles(envDir);
647
251
  const bruFiles = files.filter((f) => f.endsWith(".bru"));
648
252
  const loaded = [];
649
253
  for (const file of bruFiles) {
650
- loaded.push(await loadEnvFile(join3(envDir, file), file));
254
+ loaded.push(await loadEnvFile(join2(envDir, file), file));
651
255
  }
652
256
  return loaded;
653
257
  }
654
258
  async function scanApp(spacePath, name) {
655
- const appPath = join3(spacePath, name);
259
+ const appPath = join2(spacePath, name);
656
260
  const environments = await scanAppEnvironments(appPath);
657
261
  return { path: appPath, name, environments };
658
262
  }
@@ -661,7 +265,7 @@ async function scanSpace(orgPath, dirName) {
661
265
  if (name === void 0) {
662
266
  return void 0;
663
267
  }
664
- const spacePath = join3(orgPath, dirName);
268
+ const spacePath = join2(orgPath, dirName);
665
269
  const appDirs = await safeReaddir(spacePath);
666
270
  const apps = [];
667
271
  for (const appDir of appDirs) {
@@ -674,7 +278,7 @@ async function scanOrg(regionPath, dirName) {
674
278
  if (name === void 0) {
675
279
  return void 0;
676
280
  }
677
- const orgPath = join3(regionPath, dirName);
281
+ const orgPath = join2(regionPath, dirName);
678
282
  const spaceDirs = await safeReaddir(orgPath);
679
283
  const spaces = [];
680
284
  for (const spaceDir of spaceDirs) {
@@ -690,7 +294,7 @@ async function scanRegion(root, dirName) {
690
294
  if (key === void 0) {
691
295
  return void 0;
692
296
  }
693
- const regionPath = join3(root, dirName);
297
+ const regionPath = join2(root, dirName);
694
298
  const orgDirs = await safeReaddir(regionPath);
695
299
  const orgs = [];
696
300
  for (const orgDir of orgDirs) {
@@ -731,7 +335,7 @@ function parseShorthandPath(shorthand) {
731
335
  return environment ? { region, org, space, app, environment, filePath } : { region, org, space, app, filePath };
732
336
  }
733
337
 
734
- // src/run.ts
338
+ // src/commands/run.ts
735
339
  var require2 = createRequire(import.meta.url);
736
340
  function pathEntries(env) {
737
341
  const value = env["PATH"] ?? process.env["PATH"] ?? "";
@@ -748,7 +352,7 @@ async function findCommandOnPath(command, env) {
748
352
  const candidates = pathCandidates(command, env);
749
353
  for (const entry of pathEntries(env)) {
750
354
  for (const candidate of candidates) {
751
- const fullPath = join4(entry, candidate);
355
+ const fullPath = join3(entry, candidate);
752
356
  if (await exists(fullPath)) {
753
357
  return fullPath;
754
358
  }
@@ -777,7 +381,7 @@ function defaultResolvePackageJsonPath() {
777
381
  return require2.resolve("@usebruno/cli/package.json");
778
382
  }
779
383
  async function defaultReadTextFile(path) {
780
- return await readFile4(path, "utf8");
384
+ return await readFile3(path, "utf8");
781
385
  }
782
386
  async function resolveBundledBruBinPath(deps) {
783
387
  try {
@@ -787,7 +391,7 @@ async function resolveBundledBruBinPath(deps) {
787
391
  if (!binPath) {
788
392
  return void 0;
789
393
  }
790
- return resolve(dirname2(packageJsonPath), binPath);
394
+ return resolve(dirname(packageJsonPath), binPath);
791
395
  } catch {
792
396
  return void 0;
793
397
  }
@@ -843,7 +447,7 @@ async function resolveTarget(root, target) {
843
447
  throw new Error(`Target not found: ${target}`);
844
448
  }
845
449
  const { region, org, space, app, filePath } = shorthand;
846
- const appDir = join4(
450
+ const appDir = join3(
847
451
  root,
848
452
  regionFolderName(region),
849
453
  orgFolderName(org),
@@ -853,7 +457,7 @@ async function resolveTarget(root, target) {
853
457
  if (!filePath) {
854
458
  return { filePath: appDir, shorthand };
855
459
  }
856
- const candidate = join4(appDir, filePath);
460
+ const candidate = join3(appDir, filePath);
857
461
  if (await exists(candidate)) {
858
462
  return { filePath: candidate, shorthand };
859
463
  }
@@ -865,7 +469,7 @@ async function resolveTarget(root, target) {
865
469
  }
866
470
  async function chooseEnvironmentFile(appDir, environment) {
867
471
  if (environment) {
868
- const envFile = join4(appDir, ENVIRONMENTS_DIR, `${environment}.bru`);
472
+ const envFile = join3(appDir, ENVIRONMENTS_DIR, `${environment}.bru`);
869
473
  if (!await exists(envFile)) {
870
474
  throw new Error(`Environment file not found: ${envFile}`);
871
475
  }
@@ -897,13 +501,13 @@ function findAppDirFromFile(filePath, root) {
897
501
  if (!regionDir || !orgDir || !spaceDir || !appDir) {
898
502
  throw new Error(`File is not inside a CF-structured bruno collection: ${filePath}`);
899
503
  }
900
- return join4(root, regionDir, orgDir, spaceDir, appDir);
504
+ return join3(root, regionDir, orgDir, spaceDir, appDir);
901
505
  }
902
506
  async function persistAccessToken(envFile, token) {
903
- const raw = await readFile4(envFile, "utf8");
507
+ const raw = await readFile3(envFile, "utf8");
904
508
  const { content, changed } = upsertVars(raw, /* @__PURE__ */ new Map([["accessToken", token]]));
905
509
  if (changed) {
906
- await writeFile4(envFile, content, "utf8");
510
+ await writeFile2(envFile, content, "utf8");
907
511
  }
908
512
  }
909
513
  async function buildRunPlan(options) {
@@ -955,20 +559,308 @@ async function runBruno(options) {
955
559
  return { ...plan, ...result };
956
560
  }
957
561
 
958
- // src/use.ts
959
- function parseContextShorthand(shorthand) {
960
- const segs = shorthand.split("/").filter((s) => s.length > 0);
961
- if (segs.length !== 4) {
562
+ // src/commands/setup-app.ts
563
+ import { mkdir, readdir as readdir2, writeFile as writeFile3 } from "fs/promises";
564
+ import { basename, join as join4 } from "path";
565
+
566
+ // src/cf/info.ts
567
+ import {
568
+ getRegionView as getRegionViewApi,
569
+ readRegionsView,
570
+ readRegionView,
571
+ readStructureView,
572
+ REGION_KEYS
573
+ } from "@saptools/cf-sync";
574
+ var defaultCfInfoDeps = {
575
+ readStructureView,
576
+ readRegionsView,
577
+ readRegionView,
578
+ getRegionView: getRegionViewApi
579
+ };
580
+ function isValidRegionKey(value) {
581
+ return REGION_KEYS.includes(value);
582
+ }
583
+ async function getStructureSnapshot(deps = defaultCfInfoDeps) {
584
+ const view = await deps.readStructureView();
585
+ if (!view) {
586
+ return {
587
+ source: "empty",
588
+ structure: void 0,
589
+ stale: true,
590
+ message: "No CF structure cached. Run `cf-sync sync` first."
591
+ };
592
+ }
593
+ const stale = view.source === "runtime" && view.metadata?.status === "running";
594
+ return {
595
+ source: view.source,
596
+ structure: view.structure,
597
+ stale,
598
+ message: stale ? "A CF sync is still running \u2014 showing partial data." : void 0
599
+ };
600
+ }
601
+ async function listRegionsWithContent(deps = defaultCfInfoDeps) {
602
+ const snapshot = await getStructureSnapshot(deps);
603
+ if (!snapshot.structure) {
604
+ return [];
605
+ }
606
+ return snapshot.structure.regions.filter((r) => r.accessible && r.orgs.length > 0).map((r) => ({ key: r.key, label: r.label, orgCount: r.orgs.length }));
607
+ }
608
+ async function getRegion(key, deps = defaultCfInfoDeps) {
609
+ const view = await deps.readRegionView(key);
610
+ return view?.region;
611
+ }
612
+ function findOrg(region, orgName) {
613
+ return region.orgs.find((o) => o.name === orgName);
614
+ }
615
+ function findSpace(org, spaceName) {
616
+ return org.spaces.find((s) => s.name === spaceName);
617
+ }
618
+ function findApp(space, appName) {
619
+ return space.apps.find((a) => a.name === appName);
620
+ }
621
+ async function resolveRef(ref, deps = defaultCfInfoDeps) {
622
+ const region = await getRegion(ref.region, deps);
623
+ if (!region) {
962
624
  return void 0;
963
625
  }
964
- const [region, org, space, app] = segs;
965
- if (!region || !org || !space || !app) {
626
+ const org = findOrg(region, ref.org);
627
+ if (!org) {
628
+ return void 0;
629
+ }
630
+ const space = findSpace(org, ref.space);
631
+ if (!space) {
632
+ return void 0;
633
+ }
634
+ const app = findApp(space, ref.app);
635
+ if (!app) {
966
636
  return void 0;
967
637
  }
968
638
  return { region, org, space, app };
969
639
  }
970
- async function useContext(options) {
971
- const parsed = parseContextShorthand(options.shorthand);
640
+
641
+ // src/commands/setup-app.ts
642
+ var COMMON_ENVIRONMENTS = ["local", "dev", "staging", "prod"];
643
+ var BRUNO_COLLECTION_CONFIG_FILENAME = "bruno.json";
644
+ var ENV_NAME_PATTERN = /^[A-Za-z0-9._-]+$/;
645
+ function assertValidEnvName(name) {
646
+ if (!ENV_NAME_PATTERN.test(name)) {
647
+ throw new Error(
648
+ `Invalid environment name '${name}': only letters, digits, dot, underscore, and dash are allowed.`
649
+ );
650
+ }
651
+ }
652
+ function emptyEnvContent(envName, ref) {
653
+ const lines = [
654
+ "vars {",
655
+ ` __cf_region: ${ref.region}`,
656
+ ` __cf_org: ${ref.org}`,
657
+ ` __cf_space: ${ref.space}`,
658
+ ` __cf_app: ${ref.app}`,
659
+ ` environment: ${envName}`,
660
+ " baseUrl: ",
661
+ "}",
662
+ ""
663
+ ];
664
+ return lines.join("\n");
665
+ }
666
+ function normalizeCollectionName(root) {
667
+ const candidate = basename(root).replace(/^\.+/, "").trim();
668
+ return candidate.length > 0 ? candidate : "bruno-collection";
669
+ }
670
+ function defaultBrunoConfig(root) {
671
+ return `${JSON.stringify(
672
+ {
673
+ version: "1",
674
+ name: normalizeCollectionName(root),
675
+ type: "collection",
676
+ ignore: ["node_modules", ".git"]
677
+ },
678
+ null,
679
+ 2
680
+ )}
681
+ `;
682
+ }
683
+ async function ensureCollectionConfig(root) {
684
+ const filePath = join4(root, BRUNO_COLLECTION_CONFIG_FILENAME);
685
+ try {
686
+ await writeFile3(filePath, defaultBrunoConfig(root), { encoding: "utf8", flag: "wx" });
687
+ } catch (err) {
688
+ if (err.code !== "EEXIST") {
689
+ throw err;
690
+ }
691
+ }
692
+ }
693
+ async function ensureEnvFile(appPath, envName, ref) {
694
+ const envDir = join4(appPath, ENVIRONMENTS_DIR);
695
+ await mkdir(envDir, { recursive: true });
696
+ const filePath = join4(envDir, `${envName}.bru`);
697
+ try {
698
+ await writeFile3(filePath, emptyEnvContent(envName, ref), { encoding: "utf8", flag: "wx" });
699
+ } catch (err) {
700
+ if (err.code !== "EEXIST") {
701
+ throw err;
702
+ }
703
+ await writeCfMetaToFile(filePath, ref);
704
+ }
705
+ return filePath;
706
+ }
707
+ function pickRegion(regions) {
708
+ return regions.map((r) => ({ value: r.key, name: `${r.key} \u2014 ${r.label} (${r.orgCount.toString()} org${r.orgCount === 1 ? "" : "s"})` }));
709
+ }
710
+ function pickOrg(region) {
711
+ return region.orgs.map((o) => ({ value: o.name, name: `${o.name} (${o.spaces.length.toString()} space${o.spaces.length === 1 ? "" : "s"})` }));
712
+ }
713
+ function pickSpace(org) {
714
+ return org.spaces.map((s) => ({ value: s.name, name: `${s.name} (${s.apps.length.toString()} app${s.apps.length === 1 ? "" : "s"})` }));
715
+ }
716
+ function pickApp(space) {
717
+ return space.apps.map((a) => ({ value: a.name, name: a.name }));
718
+ }
719
+ async function selectRegion(prompts, deps) {
720
+ const regions = await listRegionsWithContent(deps);
721
+ if (regions.length === 0) {
722
+ throw new Error("No CF regions with orgs are cached. Run `cf-sync sync` first.");
723
+ }
724
+ const regionKey = await prompts.selectRegion(pickRegion(regions));
725
+ const regionView = await deps.readRegionView(regionKey);
726
+ if (!regionView) {
727
+ throw new Error(`Region ${regionKey} is not cached. Run \`cf-sync region ${regionKey}\` or \`cf-sync sync\` first.`);
728
+ }
729
+ const region = regionView.region;
730
+ if (region.orgs.length === 0) {
731
+ throw new Error(`Region ${regionKey} has no accessible orgs.`);
732
+ }
733
+ return { regionKey, region };
734
+ }
735
+ async function selectOrg(prompts, region, regionKey) {
736
+ const orgName = await prompts.selectOrg(pickOrg(region));
737
+ const org = region.orgs.find((o) => o.name === orgName);
738
+ if (!org) {
739
+ throw new Error(`Org ${orgName} not found in region ${regionKey}`);
740
+ }
741
+ if (org.spaces.length === 0) {
742
+ throw new Error(`Org ${orgName} has no spaces.`);
743
+ }
744
+ return { orgName, org };
745
+ }
746
+ async function selectSpace(prompts, org, orgName) {
747
+ const spaceName = await prompts.selectSpace(pickSpace(org));
748
+ const space = org.spaces.find((s) => s.name === spaceName);
749
+ if (!space) {
750
+ throw new Error(`Space ${spaceName} not found in org ${orgName}`);
751
+ }
752
+ if (space.apps.length === 0) {
753
+ throw new Error(`Space ${spaceName} has no apps.`);
754
+ }
755
+ return { spaceName, space };
756
+ }
757
+ async function selectCfAppRef(prompts, deps) {
758
+ const { regionKey, region } = await selectRegion(prompts, deps);
759
+ const { orgName, org } = await selectOrg(prompts, region, regionKey);
760
+ const { spaceName, space } = await selectSpace(prompts, org, orgName);
761
+ const appName = await prompts.selectApp(pickApp(space));
762
+ return { region: regionKey, org: orgName, space: spaceName, app: appName };
763
+ }
764
+ function appPathFor(root, ref) {
765
+ return join4(
766
+ root,
767
+ regionFolderName(ref.region),
768
+ orgFolderName(ref.org),
769
+ spaceFolderName(ref.space),
770
+ ref.app
771
+ );
772
+ }
773
+ function normalizeEnvironmentNames(selected) {
774
+ const merged = [];
775
+ for (const name of selected) {
776
+ const trimmed = name.trim();
777
+ if (trimmed.length === 0 || merged.includes(trimmed)) {
778
+ continue;
779
+ }
780
+ assertValidEnvName(trimmed);
781
+ merged.push(trimmed);
782
+ }
783
+ if (merged.length === 0) {
784
+ throw new Error("At least one environment is required.");
785
+ }
786
+ return merged;
787
+ }
788
+ async function selectEnvironmentNames(appPath, prompts) {
789
+ const existingEnvs = await listExistingEnvs(appPath);
790
+ const common = [...COMMON_ENVIRONMENTS];
791
+ const selected = await prompts.selectEnvironments({ common, existing: existingEnvs });
792
+ return normalizeEnvironmentNames(selected);
793
+ }
794
+ async function createEnvironmentFiles(appPath, envNames, ref, log) {
795
+ const created = [];
796
+ for (const envName of envNames) {
797
+ const path = await ensureEnvFile(appPath, envName, ref);
798
+ created.push(path);
799
+ log(`\u2022 ${path}`);
800
+ }
801
+ return created;
802
+ }
803
+ async function setupApp(options) {
804
+ const deps = options.deps ?? defaultCfInfoDeps;
805
+ const log = options.log ?? (() => void 0);
806
+ const ref = await selectCfAppRef(options.prompts, deps);
807
+ const appPath = appPathFor(options.root, ref);
808
+ const confirmed = await options.prompts.confirmCreate(appPath);
809
+ if (!confirmed) {
810
+ return { ref, appPath, environments: [], created: false };
811
+ }
812
+ await mkdir(appPath, { recursive: true });
813
+ await ensureCollectionConfig(appPath);
814
+ const envNames = await selectEnvironmentNames(appPath, options.prompts);
815
+ const created = await createEnvironmentFiles(appPath, envNames, ref, log);
816
+ return { ref, appPath, environments: created, created: true };
817
+ }
818
+ async function listExistingEnvs(appPath) {
819
+ try {
820
+ const entries = await readdir2(join4(appPath, ENVIRONMENTS_DIR), { withFileTypes: true });
821
+ return entries.filter((e) => e.isFile() && e.name.endsWith(".bru")).map((e) => e.name.replace(/\.bru$/, ""));
822
+ } catch {
823
+ return [];
824
+ }
825
+ }
826
+
827
+ // src/state/context.ts
828
+ import { mkdir as mkdir2, readFile as readFile4, writeFile as writeFile4 } from "fs/promises";
829
+ import { dirname as dirname2 } from "path";
830
+ async function readContext() {
831
+ try {
832
+ const raw = await readFile4(brunoContextPath(), "utf8");
833
+ return JSON.parse(raw);
834
+ } catch (err) {
835
+ if (err.code === "ENOENT") {
836
+ return void 0;
837
+ }
838
+ throw err;
839
+ }
840
+ }
841
+ async function writeContext(ctx) {
842
+ const updated = { ...ctx, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
843
+ const path = brunoContextPath();
844
+ await mkdir2(dirname2(path), { recursive: true });
845
+ await writeFile4(path, `${JSON.stringify(updated, null, 2)}
846
+ `, "utf8");
847
+ return updated;
848
+ }
849
+
850
+ // src/commands/use.ts
851
+ function parseContextShorthand(shorthand) {
852
+ const segs = shorthand.split("/").filter((s) => s.length > 0);
853
+ if (segs.length !== 4) {
854
+ return void 0;
855
+ }
856
+ const [region, org, space, app] = segs;
857
+ if (!region || !org || !space || !app) {
858
+ return void 0;
859
+ }
860
+ return { region, org, space, app };
861
+ }
862
+ async function useContext(options) {
863
+ const parsed = parseContextShorthand(options.shorthand);
972
864
  if (!parsed) {
973
865
  throw new Error(
974
866
  `Invalid context shorthand: ${options.shorthand}. Expected <region>/<org>/<space>/<app>.`
@@ -988,7 +880,140 @@ async function useContext(options) {
988
880
  return await writeContext(parsed);
989
881
  }
990
882
 
991
- // src/cli.ts
883
+ // src/prompts/app-search.ts
884
+ import { search } from "@inquirer/prompts";
885
+ var DEFAULT_PAGE_SIZE = 12;
886
+ var NO_MATCHING_APP = "__saptools_no_matching_app__";
887
+ function normalizeTerm(term) {
888
+ return term?.trim().toLowerCase() ?? "";
889
+ }
890
+ function scoreChoice(choice, normalizedTerm) {
891
+ const name = choice.name.toLowerCase();
892
+ const value = choice.value.toLowerCase();
893
+ if (name === normalizedTerm || value === normalizedTerm) {
894
+ return 0;
895
+ }
896
+ if (name.startsWith(normalizedTerm) || value.startsWith(normalizedTerm)) {
897
+ return 1;
898
+ }
899
+ if (name.includes(normalizedTerm) || value.includes(normalizedTerm)) {
900
+ return 2;
901
+ }
902
+ return Number.POSITIVE_INFINITY;
903
+ }
904
+ function noMatchChoice(term) {
905
+ const label = term?.trim() ?? "";
906
+ return {
907
+ value: NO_MATCHING_APP,
908
+ name: `No apps match "${label}"`,
909
+ disabled: "Type a different search term"
910
+ };
911
+ }
912
+ function buildAppSearchChoices(choices, term) {
913
+ const normalizedTerm = normalizeTerm(term);
914
+ if (normalizedTerm.length === 0) {
915
+ return [...choices];
916
+ }
917
+ const rankedMatches = choices.map((choice, index) => ({ choice, index, score: scoreChoice(choice, normalizedTerm) })).filter((item) => Number.isFinite(item.score)).sort((left, right) => left.score - right.score || left.index - right.index).map((item) => item.choice);
918
+ if (rankedMatches.length > 0) {
919
+ return rankedMatches;
920
+ }
921
+ return [noMatchChoice(term)];
922
+ }
923
+ async function promptForAppSelection(choices, deps = {}) {
924
+ const searchPrompt = deps.searchPrompt ?? search;
925
+ return await searchPrompt({
926
+ message: "Select app",
927
+ pageSize: DEFAULT_PAGE_SIZE,
928
+ source: (term) => Promise.resolve(buildAppSearchChoices(choices, term)),
929
+ validate: (value) => value === NO_MATCHING_APP ? "Select a real app." : true
930
+ });
931
+ }
932
+
933
+ // src/prompts/environment.ts
934
+ import { Separator, checkbox, input } from "@inquirer/prompts";
935
+ var ADD_CUSTOM_ENVIRONMENT = "__saptools_add_custom_environment__";
936
+ function uniqueNames(names) {
937
+ const merged = [];
938
+ for (const name of names) {
939
+ if (!merged.includes(name)) {
940
+ merged.push(name);
941
+ }
942
+ }
943
+ return merged;
944
+ }
945
+ function validateEnvironmentSelection(choices) {
946
+ const selected = choices.map((choice) => choice.value);
947
+ const hasEnvironment = selected.some((value) => value !== ADD_CUSTOM_ENVIRONMENT);
948
+ if (hasEnvironment || selected.includes(ADD_CUSTOM_ENVIRONMENT)) {
949
+ return true;
950
+ }
951
+ return 'Select at least one environment, or choose "Add custom environment".';
952
+ }
953
+ function buildEnvironmentChoices(names, selected) {
954
+ return [
955
+ ...names.map((name) => ({
956
+ value: name,
957
+ name,
958
+ checked: selected.has(name)
959
+ })),
960
+ new Separator(),
961
+ {
962
+ value: ADD_CUSTOM_ENVIRONMENT,
963
+ name: "Add custom environment",
964
+ description: "Create another environment name and return to this menu"
965
+ }
966
+ ];
967
+ }
968
+ function validateCustomEnvironmentName(value) {
969
+ const trimmed = value.trim();
970
+ if (trimmed.length === 0) {
971
+ return true;
972
+ }
973
+ try {
974
+ assertValidEnvName(trimmed);
975
+ return true;
976
+ } catch (err) {
977
+ return err instanceof Error ? err.message : String(err);
978
+ }
979
+ }
980
+ async function promptForEnvironments(opts, deps = {}) {
981
+ const checkboxPrompt = deps.checkboxPrompt ?? checkbox;
982
+ const inputPrompt = deps.inputPrompt ?? input;
983
+ const selected = new Set(opts.existing);
984
+ const customNames = [];
985
+ for (; ; ) {
986
+ const names = uniqueNames([...opts.common, ...opts.existing, ...customNames]);
987
+ const answers = await checkboxPrompt({
988
+ message: "Environments to create (space to toggle, enter to continue)",
989
+ choices: buildEnvironmentChoices(names, selected),
990
+ validate: validateEnvironmentSelection
991
+ });
992
+ selected.clear();
993
+ for (const name of answers) {
994
+ if (name !== ADD_CUSTOM_ENVIRONMENT) {
995
+ selected.add(name);
996
+ }
997
+ }
998
+ if (!answers.includes(ADD_CUSTOM_ENVIRONMENT)) {
999
+ return [...selected];
1000
+ }
1001
+ const custom = (await inputPrompt({
1002
+ message: "Custom environment name (leave empty to go back)",
1003
+ default: "",
1004
+ validate: validateCustomEnvironmentName
1005
+ })).trim();
1006
+ if (custom.length === 0) {
1007
+ continue;
1008
+ }
1009
+ if (!customNames.includes(custom) && !names.includes(custom)) {
1010
+ customNames.push(custom);
1011
+ }
1012
+ selected.add(custom);
1013
+ }
1014
+ }
1015
+
1016
+ // src/cli/main.ts
992
1017
  function resolveCollectionDir(explicitCollection, explicitRoot) {
993
1018
  if (explicitCollection) {
994
1019
  return explicitCollection;
@@ -1004,16 +1029,18 @@ function resolveCollectionDir(explicitCollection, explicitRoot) {
1004
1029
  }
1005
1030
  return process2.cwd();
1006
1031
  }
1007
- async function main(argv) {
1008
- const program = new Command();
1009
- 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());
1032
+ function resolveProgramCollectionDir(program) {
1033
+ const opts = program.opts();
1034
+ return resolveCollectionDir(opts.collection, opts.root);
1035
+ }
1036
+ function writeLine(message) {
1037
+ process2.stdout.write(`${message}
1038
+ `);
1039
+ }
1040
+ function registerSetupAppCommand(program) {
1010
1041
  program.command("setup-app").description("Interactively scaffold a bruno app folder and seed __cf_* variables").action(async () => {
1011
- const collectionDir = resolveCollectionDir(
1012
- program.opts().collection,
1013
- program.opts().root
1014
- );
1015
1042
  const result = await setupApp({
1016
- root: collectionDir,
1043
+ root: resolveProgramCollectionDir(program),
1017
1044
  prompts: {
1018
1045
  selectRegion: async (choices) => await select({ message: "Select region", choices: [...choices] }),
1019
1046
  selectOrg: async (choices) => await select({ message: "Select org", choices: [...choices] }),
@@ -1022,46 +1049,41 @@ async function main(argv) {
1022
1049
  confirmCreate: async (path) => await confirm({ message: `Create ${path}?`, default: true }),
1023
1050
  selectEnvironments: async (opts) => await promptForEnvironments(opts)
1024
1051
  },
1025
- log: (msg) => {
1026
- process2.stdout.write(`${msg}
1027
- `);
1028
- }
1052
+ log: writeLine
1029
1053
  });
1030
1054
  if (!result.created) {
1031
- process2.stdout.write("Aborted.\n");
1055
+ writeLine("Aborted.");
1032
1056
  return;
1033
1057
  }
1034
- process2.stdout.write(`\u2714 App folder ready at ${result.appPath}
1035
- `);
1058
+ writeLine(`\u2714 App folder ready at ${result.appPath}`);
1036
1059
  });
1060
+ }
1061
+ async function resolveRunTarget(target) {
1062
+ if (target) {
1063
+ return target;
1064
+ }
1065
+ const ctx = await readContext();
1066
+ if (!ctx) {
1067
+ throw new Error(
1068
+ "No target specified and no default context is set. Run `saptools-bruno use <region/org/space/app>` first."
1069
+ );
1070
+ }
1071
+ return `${ctx.region}/${ctx.org}/${ctx.space}/${ctx.app}`;
1072
+ }
1073
+ function registerRunCommand(program) {
1037
1074
  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(
1038
1075
  async (target, opts) => {
1039
- const collectionDir = resolveCollectionDir(
1040
- program.opts().collection,
1041
- program.opts().root
1042
- );
1043
- let effectiveTarget = target;
1044
- if (!effectiveTarget) {
1045
- const ctx = await readContext();
1046
- if (!ctx) {
1047
- throw new Error(
1048
- "No target specified and no default context is set. Run `saptools-bruno use <region/org/space/app>` first."
1049
- );
1050
- }
1051
- effectiveTarget = `${ctx.region}/${ctx.org}/${ctx.space}/${ctx.app}`;
1052
- }
1053
1076
  const result = await runBruno({
1054
- root: collectionDir,
1055
- target: effectiveTarget,
1077
+ root: resolveProgramCollectionDir(program),
1078
+ target: await resolveRunTarget(target),
1056
1079
  ...opts.env ? { environment: opts.env } : {},
1057
- log: (msg) => {
1058
- process2.stdout.write(`${msg}
1059
- `);
1060
- }
1080
+ log: writeLine
1061
1081
  });
1062
1082
  process2.exit(result.code);
1063
1083
  }
1064
1084
  );
1085
+ }
1086
+ function registerUseCommand(program) {
1065
1087
  program.command("use").description("Set the default CF context (region/org/space/app) for future `run` calls").argument("<shorthand>", "region/org/space/app").option("--no-verify", "Skip verifying the context against the cached CF structure").action(async (shorthand, opts) => {
1066
1088
  const ctx = await useContext({
1067
1089
  shorthand,
@@ -1070,6 +1092,13 @@ async function main(argv) {
1070
1092
  process2.stdout.write(`\u2714 Default context set to ${ctx.region}/${ctx.org}/${ctx.space}/${ctx.app}
1071
1093
  `);
1072
1094
  });
1095
+ }
1096
+ async function main(argv) {
1097
+ const program = new Command();
1098
+ 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());
1099
+ registerSetupAppCommand(program);
1100
+ registerRunCommand(program);
1101
+ registerUseCommand(program);
1073
1102
  await program.parseAsync([...argv]);
1074
1103
  }
1075
1104
  try {