@saptools/bruno 0.3.4 → 0.3.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 CHANGED
@@ -13,7 +13,7 @@ Scaffold a CF-aware collection. Resolve requests by `region/org/space/app` short
13
13
  [![types](https://img.shields.io/npm/types/@saptools/bruno.svg?style=flat&color=3178C6&logo=typescript&logoColor=white)](https://www.typescriptlang.org)
14
14
  [![build](https://img.shields.io/github/actions/workflow/status/dongitran/saptools/bruno.yml?style=flat&logo=github&label=CI)](https://github.com/dongitran/saptools/actions/workflows/bruno.yml)
15
15
 
16
- [**Install**](#-install) · [**Quick Start**](#-quick-start) · [**CLI**](#-cli) · [**API**](#-programmatic-usage) · [**FAQ**](#-faq) · [**Roadmap**](#-roadmap)
16
+ [**Install**](#-install) · [**Quick Start**](#-quick-start) · [**CLI**](#-cli) · [**FAQ**](#-faq) · [**Roadmap**](#-roadmap)
17
17
 
18
18
  </div>
19
19
 
@@ -22,10 +22,12 @@ Scaffold a CF-aware collection. Resolve requests by `region/org/space/app` short
22
22
  ## ⚡ At a glance
23
23
 
24
24
  ```console
25
- $ saptools-bruno use ap10/demo-prod/api/orders-srv
25
+ $ bruno use ap10/demo-prod/api/orders-srv
26
26
  ✔ Default context set to ap10/demo-prod/api/orders-srv
27
27
 
28
- $ saptools-bruno run --env dev
28
+ $ bruno
29
+ # chọn Run .bru file (folder tree)
30
+ # duyệt cây thư mục rồi chọn file .bru để chạy
29
31
  ▶ bru run --env dev --env-var accessToken=eyJhbGciOi… (cwd=…/orders-srv)
30
32
  Running Folder Recursively
31
33
  ✓ GET /orders 204 OK 54ms
@@ -43,8 +45,8 @@ You just ran Bruno against a production-grade XSUAA-protected service **without
43
45
  - 🏗️ **Interactive `setup-app`** — pick a region → org → space, then **search apps as you type** before choosing exactly the environments you want (or typing a custom name like `qa-eu`). Every env file is seeded with `__cf_*` metadata so the runner knows where to fetch a token.
44
46
  - 🧭 **Shorthand paths** — `region/org/space/app[/folder/file.bru]` expands to the right filesystem path. No more `cd`-ing through nested folders.
45
47
  - 🔐 **Automatic XSUAA tokens** — every `run` fetches (or reuses) a cached token via [`@saptools/cf-xsuaa`](https://www.npmjs.com/package/@saptools/cf-xsuaa), writes it into the selected env file as `accessToken`, and still injects it for `bru` at execution time.
46
- - 📦 **Bundled Bruno CLI fallback** — if `bru` is already on your `PATH`, `saptools-bruno` uses it. If not, it falls back to the bundled [`@usebruno/cli`](https://www.npmjs.com/package/@usebruno/cli).
47
- - 🎯 **Default context** — `saptools-bruno use <shorthand>` pins a target so subsequent `run` calls need zero arguments. Feels like `cf target` for Bruno.
48
+ - 📦 **Bundled Bruno CLI fallback** — if `bru` is already on your `PATH`, `bruno` uses it. If not, it falls back to the bundled [`@usebruno/cli`](https://www.npmjs.com/package/@usebruno/cli).
49
+ - 🎯 **Default context** — `bruno use <shorthand>` pins a target so subsequent `run` calls need zero arguments. Feels like `cf target` for Bruno.
48
50
  - 🧩 **CLI & typed API** — every command has a zero-config Node.js equivalent. Full TypeScript definitions shipped. Bring your own prompts for headless/CI use.
49
51
  - 🧪 **Fully tested** — unit tests plus offline e2e coverage (stub `bru` binary + fixture CF snapshot). No network required in CI.
50
52
  - 🪶 **Small + boring** — a small runtime surface, no background daemons, no plugin system, no magic.
@@ -80,8 +82,8 @@ You just ran Bruno against a production-grade XSUAA-protected service **without
80
82
  <td valign="top">
81
83
 
82
84
  ```bash
83
- saptools-bruno use ap10/demo-prod/api/orders-srv
84
- saptools-bruno run --env dev
85
+ bruno use ap10/demo-prod/api/orders-srv
86
+ bruno run --env dev
85
87
  ```
86
88
 
87
89
  *That's it. Token is cached, refreshed on expiry, written back to the env file, and injected automatically.*
@@ -118,13 +120,13 @@ npm install @saptools/bruno
118
120
  cf-sync sync
119
121
 
120
122
  # 2. Scaffold an app folder with seeded __cf_* metadata
121
- saptools-bruno setup-app
123
+ bruno setup-app
122
124
 
123
125
  # 3. Pin a default CF context so future runs need zero args
124
- saptools-bruno use ap10/my-org/dev/my-srv
126
+ bruno use ap10/my-org/dev/my-srv
125
127
 
126
128
  # 4. Run — XSUAA token is fetched, written to the env file, and injected automatically
127
- saptools-bruno run --env dev
129
+ bruno run --env dev
128
130
  ```
129
131
 
130
132
  After `setup-app`, your workspace looks like this:
@@ -160,17 +162,38 @@ Your `.bru` requests reference `{{accessToken}}` like any other Bruno variable
160
162
 
161
163
  ## 🧰 CLI
162
164
 
163
- ### 🏗️ `saptools-bruno setup-app`
165
+
166
+ ### 🗂️ `bruno` (interactive tree mode)
167
+
168
+ Chạy `bruno` không kèm subcommand sẽ mở menu tương tác:
169
+ - Run `.bru` file
170
+ - Set default context
171
+ - Setup app folder
172
+ - Set Bruno root folder
173
+
174
+ Ở chế độ chạy file, danh sách browse chỉ hiển thị cấp thư mục hiện tại (tên gần nhất). Bạn có thể duyệt vào thư mục con/back, hoặc chọn `Search .bru file` để tìm toàn cây và kết quả sẽ hiển thị theo đường dẫn tree tương đối.
175
+
176
+ ### 📌 `bruno set-root [dir]`
177
+
178
+ Lưu Bruno root folder mặc định vào `~/.saptools/bruno/cli-state.json`.
179
+
180
+ ```bash
181
+ bruno set-root /path/to/your/collection
182
+ ```
183
+
184
+ Sau khi set, lần gọi `bruno` tiếp theo sẽ dùng root này nếu bạn không truyền `--collection` và không set env var.
185
+
186
+ ### 🏗️ `bruno setup-app`
164
187
 
165
188
  Interactively scaffold a Bruno app folder inside the current Bruno collection directory. Walks you through **region → org → space → app**, with the **app step using a searchable picker** for large spaces, then lets you **pick which environments to create** and add custom names without leaving the environment picker.
166
189
 
167
190
  ```bash
168
- saptools-bruno setup-app
169
- saptools-bruno --collection ./collections setup-app
191
+ bruno setup-app
192
+ bruno --collection ./collections setup-app
170
193
  ```
171
194
 
172
195
  > [!TIP]
173
- > `--collection` only applies to the current command. If you omit it, `saptools-bruno` falls back to `$SAPTOOLS_BRUNO_COLLECTION`, then to your current working directory.
196
+ > `--collection` only applies to the current command. If you omit it, `bruno` falls back to `$SAPTOOLS_BRUNO_COLLECTION`, then to your current working directory.
174
197
 
175
198
  > [!IMPORTANT]
176
199
  > `setup-app` reads the cached CF landscape prepared by `cf-sync`. If the cache is missing or stale, run `cf-sync sync` first.
@@ -185,22 +208,22 @@ saptools-bruno --collection ./collections setup-app
185
208
  > [!TIP]
186
209
  > The env prompt shows the common names (`local`, `dev`, `staging`, `prod`) plus any envs already on disk. Pre-existing envs are **pre-checked**; common ones are **not** — so you only create what you actually need. The menu also includes **Add custom environment**, and once you enter a value like `qa-eu` or `uat.us`, it appears back in the same checklist already selected so you can review the full set before finishing.
187
210
 
188
- ### ▶️ `saptools-bruno run`
211
+ ### ▶️ `bruno run`
189
212
 
190
213
  Run a Bruno request or folder, refreshing `accessToken` in the chosen env file and auto-injecting the same token for the current execution.
191
214
 
192
215
  ```bash
193
216
  # Use the default context
194
- saptools-bruno run --env dev
217
+ bruno run --env dev
195
218
 
196
219
  # Explicit shorthand
197
- saptools-bruno run ap10/my-org/dev/my-srv --env dev
220
+ bruno run ap10/my-org/dev/my-srv --env dev
198
221
 
199
222
  # Drill into a subfolder or a single file
200
- saptools-bruno run ap10/my-org/dev/my-srv/users/get-all.bru --env dev
223
+ bruno run ap10/my-org/dev/my-srv/users/get-all.bru --env dev
201
224
 
202
225
  # Or pass a real filesystem path (absolute or relative)
203
- saptools-bruno run ./region__ap10/org__my-org/space__dev/my-srv --env dev
226
+ bruno run ./region__ap10/org__my-org/space__dev/my-srv --env dev
204
227
  ```
205
228
 
206
229
  | Flag | Description |
@@ -213,13 +236,13 @@ Under the hood this:
213
236
  - writes `accessToken: <token>` into the selected `.bru` env file
214
237
  - spawns `bru run <target> --env <name> --env-var accessToken=<token>`
215
238
 
216
- ### 🎯 `saptools-bruno use`
239
+ ### 🎯 `bruno use`
217
240
 
218
241
  Pin a default CF context so `run` can be called without arguments.
219
242
 
220
243
  ```bash
221
- saptools-bruno use ap10/my-org/dev/my-srv
222
- saptools-bruno use ap10/my-org/dev/my-srv --no-verify
244
+ bruno use ap10/my-org/dev/my-srv
245
+ bruno use ap10/my-org/dev/my-srv --no-verify
223
246
  ```
224
247
 
225
248
  | Flag | Description |
@@ -230,82 +253,6 @@ The context lives at `~/.saptools/bruno-context.json`.
230
253
 
231
254
  ---
232
255
 
233
- ## 🧑‍💻 Programmatic Usage
234
-
235
- ```ts
236
- import {
237
- buildRunPlan,
238
- readContext,
239
- runBruno,
240
- scanCollection,
241
- setupApp,
242
- useContext,
243
- } from "@saptools/bruno";
244
-
245
- // 1. Scaffold an app folder (BYO prompts — perfect for headless/CI)
246
- const result = await setupApp({
247
- root: "./collections",
248
- prompts: {
249
- selectRegion: async (choices) => choices[0]!.value,
250
- selectOrg: async (choices) => choices[0]!.value,
251
- selectSpace: async (choices) => choices[0]!.value,
252
- selectApp: async (choices) => choices[0]!.value,
253
- confirmCreate: async () => true,
254
- selectEnvironments: async ({ common }) => [...common, "qa-eu"],
255
- },
256
- });
257
- console.log(`Created ${result.environments.length} env files at ${result.appPath}`);
258
-
259
- // 2. Pin a default context for later runs
260
- await useContext({ shorthand: "ap10/my-org/dev/my-srv" });
261
-
262
- // 3. Run Bruno — token is fetched and injected for you
263
- const run = await runBruno({
264
- root: "./collections",
265
- target: "ap10/my-org/dev/my-srv",
266
- environment: "dev",
267
- });
268
- process.exit(run.code);
269
-
270
- // 4. Need the plan without spawning `bru`? (CI dry-runs, IDE integrations)
271
- const plan = await buildRunPlan({
272
- root: "./collections",
273
- target: "ap10/my-org/dev/my-srv",
274
- environment: "dev",
275
- });
276
- console.log(plan.bruArgs);
277
- // → ["run", "--env", "dev", "--env-var", "accessToken=..."]
278
-
279
- // 5. Walk a whole collection to build a UI tree
280
- const tree = await scanCollection("./collections");
281
- console.log(tree.regions.map((r) => r.key));
282
-
283
- // 6. Inspect the active default context
284
- const ctx = await readContext();
285
- console.log(ctx?.app);
286
- ```
287
-
288
- <details>
289
- <summary><b>📚 Full export list</b></summary>
290
-
291
- | Export | Description |
292
- | --- | --- |
293
- | `setupApp(options)` | Interactive app-folder scaffolder with pluggable prompts |
294
- | `COMMON_ENVIRONMENTS` | Default environment-name suggestions (`local`, `dev`, `staging`, `prod`) |
295
- | `runBruno(options)` | Build a plan and spawn `bru run` with token injected |
296
- | `buildRunPlan(options)` | Build the plan (args, cwd, env file, token) without spawning |
297
- | `useContext({ shorthand, verify })` | Pin a default region/org/space/app context |
298
- | `readContext()` | Read the pinned context, or `undefined` |
299
- | `writeContext(ctx)` | Persist a new default context |
300
- | `scanCollection(root)` | Walk the folder tree and return a typed `region → org → space → app → env` view |
301
- | `parseShorthandPath(shorthand)` | Split `region/org/space/app[/file]` into a typed ref |
302
- | `parseBruEnvFile(raw)` / `writeBruEnvFile(...)` | Minimal `.bru` env reader/writer |
303
- | `readCfMetaFromFile(path)` / `writeCfMetaToFile(path, ref)` | Round-trip `__cf_*` vars in an env file |
304
-
305
- </details>
306
-
307
- ---
308
-
309
256
  ## 📁 Folder Layout
310
257
 
311
258
  All state lives under your home directory or your collection root:
@@ -365,7 +312,7 @@ The `__cf_*` vars drive XSUAA lookup. `run` adds `accessToken` on the fly via `b
365
312
  | Hand-edit `environments/*.bru` | ❌ manual | ❌ | ❌ | ❌ | ❌ |
366
313
  | Bruno GUI OAuth2 | ✅ | ❌ | ❌ | partial | ❌ (GUI) |
367
314
  | `bru run` alone | ❌ | ❌ | ❌ | ❌ | ✅ |
368
- | **`saptools-bruno`** | ✅ **automatic** | ✅ | ✅ | ✅ | ✅ |
315
+ | **`bruno`** | ✅ **automatic** | ✅ | ✅ | ✅ | ✅ |
369
316
 
370
317
  ---
371
318
 
@@ -384,7 +331,7 @@ The `__cf_*` vars drive XSUAA lookup. `run` adds `accessToken` on the fly via `b
384
331
  <details>
385
332
  <summary><b>Why does Bruno need a wrapper — can't I just call <code>bru run</code>?</b></summary>
386
333
 
387
- You can, but every CF service behind XSUAA needs a fresh OAuth2 token, and Bruno doesn't mint them. `saptools-bruno run` fetches the token (cached when possible), injects it as `accessToken`, and gets out of the way. Your `.bru` requests stay portable.
334
+ You can, but every CF service behind XSUAA needs a fresh OAuth2 token, and Bruno doesn't mint them. `bruno run` fetches the token (cached when possible), injects it as `accessToken`, and gets out of the way. Your `.bru` requests stay portable.
388
335
 
389
336
  </details>
390
337
 
@@ -431,8 +378,8 @@ Use the programmatic API with your own prompt stubs (every field just returns th
431
378
  - [x] Shorthand path resolution (`region/org/space/app[/file]`)
432
379
  - [x] Default CF context via `use`
433
380
  - [x] Offline e2e via stubbed `bru`
434
- - [ ] `saptools-bruno doctor` — diagnose missing `__cf_*` vars, stale tokens, missing `bru`
435
- - [ ] `saptools-bruno migrate` — move collections from a flat layout into the CF-aware layout
381
+ - [ ] `bruno doctor` — diagnose missing `__cf_*` vars, stale tokens, missing `bru`
382
+ - [ ] `bruno migrate` — move collections from a flat layout into the CF-aware layout
436
383
  - [ ] First-class `--reporter json` support for piping test results into dashboards
437
384
 
438
385
  Have an idea? [Open an issue](https://github.com/dongitran/saptools/issues/new) — the roadmap is driven by real use.
package/dist/cli.js CHANGED
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli/main.ts
4
+ import { readdir as readdir3 } from "fs/promises";
5
+ import { isAbsolute as isAbsolute2, join as join5, resolve as resolve2 } from "path";
4
6
  import process2 from "process";
5
- import { confirm, select } from "@inquirer/prompts";
7
+ import { confirm, input as input2, search as search2, select } from "@inquirer/prompts";
6
8
  import { Command, Option } from "commander";
7
9
 
8
10
  // src/commands/run.ts
@@ -191,6 +193,8 @@ import { homedir } from "os";
191
193
  import { join } from "path";
192
194
  var SAPTOOLS_DIR_NAME = ".saptools";
193
195
  var BRUNO_CONTEXT_FILENAME = "bruno-context.json";
196
+ var BRUNO_DIR_NAME = "bruno";
197
+ var BRUNO_CLI_STATE_FILENAME = "cli-state.json";
194
198
  var REGION_FOLDER_PREFIX = "region__";
195
199
  var ORG_FOLDER_PREFIX = "org__";
196
200
  var SPACE_FOLDER_PREFIX = "space__";
@@ -216,6 +220,12 @@ function parsePrefixedName(dirName, prefix) {
216
220
  }
217
221
  return dirName.slice(prefix.length);
218
222
  }
223
+ function brunoDir() {
224
+ return join(saptoolsDir(), BRUNO_DIR_NAME);
225
+ }
226
+ function brunoCliStatePath() {
227
+ return join(brunoDir(), BRUNO_CLI_STATE_FILENAME);
228
+ }
219
229
 
220
230
  // src/collection/folder-scan.ts
221
231
  async function safeReaddir(path) {
@@ -846,6 +856,28 @@ async function writeContext(ctx) {
846
856
  `, "utf8");
847
857
  return updated;
848
858
  }
859
+ async function readBrunoCliState() {
860
+ try {
861
+ const raw = await readFile4(brunoCliStatePath(), "utf8");
862
+ return JSON.parse(raw);
863
+ } catch (err) {
864
+ if (err.code === "ENOENT") {
865
+ return void 0;
866
+ }
867
+ throw err;
868
+ }
869
+ }
870
+ async function writeBrunoCliState(input3) {
871
+ const next = {
872
+ rootDir: input3.rootDir,
873
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
874
+ };
875
+ const path = brunoCliStatePath();
876
+ await mkdir2(dirname2(path), { recursive: true });
877
+ await writeFile4(path, `${JSON.stringify(next, null, 2)}
878
+ `, "utf8");
879
+ return next;
880
+ }
849
881
 
850
882
  // src/commands/use.ts
851
883
  function parseContextShorthand(shorthand) {
@@ -1016,18 +1048,18 @@ async function promptForEnvironments(opts, deps = {}) {
1016
1048
  // src/cli/main.ts
1017
1049
  function resolveCollectionDir(explicitCollection, explicitRoot) {
1018
1050
  if (explicitCollection) {
1019
- return explicitCollection;
1051
+ return Promise.resolve(explicitCollection);
1020
1052
  }
1021
1053
  if (explicitRoot) {
1022
- return explicitRoot;
1054
+ return Promise.resolve(explicitRoot);
1023
1055
  }
1024
1056
  if (process2.env["SAPTOOLS_BRUNO_COLLECTION"]) {
1025
- return process2.env["SAPTOOLS_BRUNO_COLLECTION"];
1057
+ return Promise.resolve(process2.env["SAPTOOLS_BRUNO_COLLECTION"]);
1026
1058
  }
1027
1059
  if (process2.env["SAPTOOLS_BRUNO_ROOT"]) {
1028
- return process2.env["SAPTOOLS_BRUNO_ROOT"];
1060
+ return Promise.resolve(process2.env["SAPTOOLS_BRUNO_ROOT"]);
1029
1061
  }
1030
- return process2.cwd();
1062
+ return readBrunoCliState().then((state) => state?.rootDir ?? process2.cwd());
1031
1063
  }
1032
1064
  function resolveProgramCollectionDir(program) {
1033
1065
  const opts = program.opts();
@@ -1037,10 +1069,121 @@ function writeLine(message) {
1037
1069
  process2.stdout.write(`${message}
1038
1070
  `);
1039
1071
  }
1072
+ async function listDir(path) {
1073
+ const entries = await readdir3(path, { withFileTypes: true });
1074
+ const dirs = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
1075
+ const bruFiles = entries.filter((entry) => entry.isFile() && entry.name.endsWith(".bru")).map((entry) => entry.name).sort();
1076
+ return { dirs, bruFiles };
1077
+ }
1078
+ async function collectBruFiles(root, current) {
1079
+ const { dirs, bruFiles } = await listDir(current);
1080
+ const nested = await Promise.all(dirs.map(async (dir) => await collectBruFiles(root, join5(current, dir))));
1081
+ const currentFiles = bruFiles.map((name) => join5(current, name));
1082
+ return [...currentFiles, ...nested.flat()].sort();
1083
+ }
1084
+ function formatTreePath(root, fullPath) {
1085
+ const rel = fullPath.startsWith(root) ? fullPath.slice(root.length).replace(/^[/\\]/, "") : fullPath;
1086
+ return rel.replace(/\\/g, "/");
1087
+ }
1088
+ function formatTreeResultLabel(relativePath) {
1089
+ const segments = relativePath.split("/").filter((segment) => segment.length > 0);
1090
+ if (segments.length === 0) {
1091
+ return "\u{1F4C4}";
1092
+ }
1093
+ const lines = [];
1094
+ for (const [index, segment] of segments.entries()) {
1095
+ const isFile = index === segments.length - 1;
1096
+ const icon = isFile ? "\u{1F4C4}" : "\u{1F4C1}";
1097
+ if (index === 0) {
1098
+ lines.push(`${icon} ${segment}`);
1099
+ continue;
1100
+ }
1101
+ const indent = " ".repeat(index);
1102
+ lines.push(`${indent}\u2514\u2500${icon} ${segment}`);
1103
+ }
1104
+ return lines.join("\n");
1105
+ }
1106
+ function formatEntryTreePath(root, current, entryName) {
1107
+ const fullPath = join5(current, entryName);
1108
+ return formatTreePath(root, fullPath);
1109
+ }
1110
+ async function browseBruFile(root) {
1111
+ let current = root;
1112
+ for (; ; ) {
1113
+ const currentLevel = await listDir(current);
1114
+ const allFiles = await collectBruFiles(root, root);
1115
+ const selected = await search2({
1116
+ message: "",
1117
+ source: (inputValue) => {
1118
+ const query = (inputValue ?? "").trim().toLowerCase();
1119
+ if (query.length === 0) {
1120
+ const localChoices = [
1121
+ { name: "\u{1F50E}", value: { kind: "search", name: "" } },
1122
+ ...currentLevel.dirs.map((dir) => ({
1123
+ name: `\u{1F4C1} ${dir}/`,
1124
+ value: { kind: "dir", name: dir }
1125
+ })),
1126
+ ...currentLevel.bruFiles.map((file) => ({
1127
+ name: `\u{1F4C4} ${file}`,
1128
+ value: { kind: "file", name: file }
1129
+ })),
1130
+ ...current === root ? [] : [{ name: "\u2B05 ../", value: { kind: "back", name: "" } }],
1131
+ { name: "\u2716 Exit", value: { kind: "exit", name: "" } }
1132
+ ];
1133
+ return localChoices;
1134
+ }
1135
+ const matchedFiles = allFiles.filter((file) => formatTreePath(root, file).toLowerCase().includes(query)).slice(0, 200).map((file) => ({
1136
+ name: formatTreeResultLabel(formatTreePath(root, file)),
1137
+ value: { kind: "file", name: formatEntryTreePath(root, root, formatTreePath(root, file)) }
1138
+ }));
1139
+ const matchedDirs = Array.from(
1140
+ new Set(
1141
+ allFiles.map((file) => formatTreePath(root, file)).flatMap((path) => {
1142
+ const parts = path.split("/");
1143
+ if (parts.length <= 1) {
1144
+ return [];
1145
+ }
1146
+ const folders = parts.slice(0, -1);
1147
+ return folders.map((_, index) => folders.slice(0, index + 1).join("/"));
1148
+ }).filter((dirPath) => dirPath.toLowerCase().includes(query))
1149
+ )
1150
+ ).slice(0, 100).map((dirPath) => ({
1151
+ name: formatTreeResultLabel(dirPath),
1152
+ value: { kind: "dir", name: dirPath }
1153
+ }));
1154
+ return [
1155
+ { name: `\u{1F50E} ${query}`, value: { kind: "search", name: query } },
1156
+ ...matchedDirs,
1157
+ ...matchedFiles,
1158
+ ...current === root ? [] : [{ name: "\u2B05 ../", value: { kind: "back", name: "" } }],
1159
+ { name: "\u2716 Exit", value: { kind: "exit", name: "" } }
1160
+ ];
1161
+ }
1162
+ });
1163
+ if (selected.kind === "dir") {
1164
+ current = selected.name.includes("/") ? join5(root, selected.name) : join5(current, selected.name);
1165
+ continue;
1166
+ }
1167
+ if (selected.kind === "back") {
1168
+ current = resolve2(current, "..");
1169
+ continue;
1170
+ }
1171
+ if (selected.kind === "search") {
1172
+ continue;
1173
+ }
1174
+ if (selected.kind === "file") {
1175
+ if (selected.name.endsWith(".bru") && selected.name.includes("/")) {
1176
+ return join5(root, selected.name);
1177
+ }
1178
+ return join5(current, selected.name);
1179
+ }
1180
+ return void 0;
1181
+ }
1182
+ }
1040
1183
  function registerSetupAppCommand(program) {
1041
1184
  program.command("setup-app").description("Interactively scaffold a bruno app folder and seed __cf_* variables").action(async () => {
1042
1185
  const result = await setupApp({
1043
- root: resolveProgramCollectionDir(program),
1186
+ root: await resolveProgramCollectionDir(program),
1044
1187
  prompts: {
1045
1188
  selectRegion: async (choices) => await select({ message: "Select region", choices: [...choices] }),
1046
1189
  selectOrg: async (choices) => await select({ message: "Select org", choices: [...choices] }),
@@ -1064,41 +1207,82 @@ async function resolveRunTarget(target) {
1064
1207
  }
1065
1208
  const ctx = await readContext();
1066
1209
  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
- );
1210
+ throw new Error("No target specified and no default context is set. Run `bruno use <region/org/space/app>` first.");
1070
1211
  }
1071
1212
  return `${ctx.region}/${ctx.org}/${ctx.space}/${ctx.app}`;
1072
1213
  }
1073
1214
  function registerRunCommand(program) {
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(
1075
- async (target, opts) => {
1076
- const result = await runBruno({
1077
- root: resolveProgramCollectionDir(program),
1078
- target: await resolveRunTarget(target),
1079
- ...opts.env ? { environment: opts.env } : {},
1080
- log: writeLine
1081
- });
1082
- process2.exit(result.code);
1083
- }
1084
- );
1215
+ 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(async (target, opts) => {
1216
+ const result = await runBruno({
1217
+ root: await resolveProgramCollectionDir(program),
1218
+ target: await resolveRunTarget(target),
1219
+ ...opts.env ? { environment: opts.env } : {},
1220
+ log: writeLine
1221
+ });
1222
+ process2.exit(result.code);
1223
+ });
1085
1224
  }
1086
1225
  function registerUseCommand(program) {
1087
1226
  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) => {
1088
- const ctx = await useContext({
1089
- shorthand,
1090
- verify: opts.verify !== false
1091
- });
1227
+ const ctx = await useContext({ shorthand, verify: opts.verify !== false });
1092
1228
  process2.stdout.write(`\u2714 Default context set to ${ctx.region}/${ctx.org}/${ctx.space}/${ctx.app}
1093
1229
  `);
1094
1230
  });
1095
1231
  }
1232
+ function registerSetRootCommand(program) {
1233
+ program.command("set-root").description("Persist default Bruno root folder under ~/.saptools/bruno/").argument("[dir]", "Root folder path").action(async (dir) => {
1234
+ const raw = dir ?? await input2({ message: "Enter Bruno root folder path", default: process2.cwd() });
1235
+ const rootDir = isAbsolute2(raw) ? raw : resolve2(process2.cwd(), raw);
1236
+ const saved = await writeBrunoCliState({ rootDir });
1237
+ writeLine(`\u2714 Bruno root saved: ${saved.rootDir}`);
1238
+ });
1239
+ }
1240
+ async function launchInteractiveMenu(program) {
1241
+ const root = await resolveProgramCollectionDir(program);
1242
+ const action = await select({
1243
+ message: `Bruno menu (${root}) \u2014 choose an action`,
1244
+ choices: [
1245
+ { name: "Run .bru file", value: "run-file" },
1246
+ { name: "Set default context (use)", value: "use" },
1247
+ { name: "Setup app folder", value: "setup-app" },
1248
+ { name: "Set Bruno root folder", value: "set-root" },
1249
+ { name: "Exit", value: "exit" }
1250
+ ]
1251
+ });
1252
+ if (action === "run-file") {
1253
+ const filePath = await browseBruFile(root);
1254
+ if (!filePath) {
1255
+ writeLine("Aborted.");
1256
+ return;
1257
+ }
1258
+ const result = await runBruno({ root, target: filePath, log: writeLine });
1259
+ process2.exit(result.code);
1260
+ }
1261
+ if (action === "use") {
1262
+ const shorthand = await input2({ message: "Enter context shorthand (region/org/space/app)" });
1263
+ const ctx = await useContext({ shorthand, verify: true });
1264
+ writeLine(`\u2714 Default context set to ${ctx.region}/${ctx.org}/${ctx.space}/${ctx.app}`);
1265
+ return;
1266
+ }
1267
+ if (action === "setup-app") {
1268
+ await program.parseAsync(["node", "bruno", "setup-app"]);
1269
+ return;
1270
+ }
1271
+ if (action === "set-root") {
1272
+ await program.parseAsync(["node", "bruno", "set-root"]);
1273
+ }
1274
+ }
1096
1275
  async function main(argv) {
1097
1276
  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());
1277
+ program.name("bruno").description("Smart runner for Bruno with CF-aware env metadata and automatic token injection").addOption(new Option("--collection <dir>", "Bruno collection directory (default: configured root, SAPTOOLS_BRUNO_COLLECTION or cwd)")).addOption(new Option("--root <dir>", "Legacy alias for --collection").hideHelp());
1099
1278
  registerSetupAppCommand(program);
1100
1279
  registerRunCommand(program);
1101
1280
  registerUseCommand(program);
1281
+ registerSetRootCommand(program);
1282
+ if (argv.length <= 2) {
1283
+ await launchInteractiveMenu(program);
1284
+ return;
1285
+ }
1102
1286
  await program.parseAsync([...argv]);
1103
1287
  }
1104
1288
  try {