@saptools/bruno 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +852 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +259 -0
- package/dist/index.js +801 -0
- package/dist/index.js.map +1 -0
- package/package.json +55 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,852 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import process2 from "process";
|
|
5
|
+
import { checkbox, confirm, input, select } from "@inquirer/prompts";
|
|
6
|
+
import { Command } from "commander";
|
|
7
|
+
|
|
8
|
+
// src/context.ts
|
|
9
|
+
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
10
|
+
import { dirname } from "path";
|
|
11
|
+
|
|
12
|
+
// src/paths.ts
|
|
13
|
+
import { homedir } from "os";
|
|
14
|
+
import { join } from "path";
|
|
15
|
+
var SAPTOOLS_DIR_NAME = ".saptools";
|
|
16
|
+
var BRUNO_CONTEXT_FILENAME = "bruno-context.json";
|
|
17
|
+
var REGION_FOLDER_PREFIX = "region__";
|
|
18
|
+
var ORG_FOLDER_PREFIX = "org__";
|
|
19
|
+
var SPACE_FOLDER_PREFIX = "space__";
|
|
20
|
+
var ENVIRONMENTS_DIR = "environments";
|
|
21
|
+
function saptoolsDir() {
|
|
22
|
+
return join(homedir(), SAPTOOLS_DIR_NAME);
|
|
23
|
+
}
|
|
24
|
+
function brunoContextPath() {
|
|
25
|
+
return join(saptoolsDir(), BRUNO_CONTEXT_FILENAME);
|
|
26
|
+
}
|
|
27
|
+
function regionFolderName(key) {
|
|
28
|
+
return `${REGION_FOLDER_PREFIX}${key}`;
|
|
29
|
+
}
|
|
30
|
+
function orgFolderName(name) {
|
|
31
|
+
return `${ORG_FOLDER_PREFIX}${name}`;
|
|
32
|
+
}
|
|
33
|
+
function spaceFolderName(name) {
|
|
34
|
+
return `${SPACE_FOLDER_PREFIX}${name}`;
|
|
35
|
+
}
|
|
36
|
+
function parsePrefixedName(dirName, prefix) {
|
|
37
|
+
if (!dirName.startsWith(prefix)) {
|
|
38
|
+
return void 0;
|
|
39
|
+
}
|
|
40
|
+
return dirName.slice(prefix.length);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// src/context.ts
|
|
44
|
+
async function readContext() {
|
|
45
|
+
try {
|
|
46
|
+
const raw = await readFile(brunoContextPath(), "utf8");
|
|
47
|
+
return JSON.parse(raw);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
if (err.code === "ENOENT") {
|
|
50
|
+
return void 0;
|
|
51
|
+
}
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async function writeContext(ctx) {
|
|
56
|
+
const updated = { ...ctx, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
57
|
+
const path = brunoContextPath();
|
|
58
|
+
await mkdir(dirname(path), { recursive: true });
|
|
59
|
+
await writeFile(path, `${JSON.stringify(updated, null, 2)}
|
|
60
|
+
`, "utf8");
|
|
61
|
+
return updated;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// src/run.ts
|
|
65
|
+
import { spawn } from "child_process";
|
|
66
|
+
import { stat } from "fs/promises";
|
|
67
|
+
import { isAbsolute, join as join3, relative, resolve, sep } from "path";
|
|
68
|
+
import { getTokenCached as getTokenCachedApi } from "@saptools/cf-xsuaa";
|
|
69
|
+
|
|
70
|
+
// src/cf-meta.ts
|
|
71
|
+
import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
|
|
72
|
+
|
|
73
|
+
// src/bru-parser.ts
|
|
74
|
+
var HEADER_REGEX = /(^|\n)\s*([a-zA-Z][a-zA-Z0-9:_-]*)\s*([{[])/g;
|
|
75
|
+
function findMatchingClose(raw, open, openIdx) {
|
|
76
|
+
const close = open === "{" ? "}" : "]";
|
|
77
|
+
let depth = 1;
|
|
78
|
+
let i = openIdx + 1;
|
|
79
|
+
while (i < raw.length) {
|
|
80
|
+
const ch = raw[i];
|
|
81
|
+
if (ch === open) {
|
|
82
|
+
depth++;
|
|
83
|
+
} else if (ch === close) {
|
|
84
|
+
depth--;
|
|
85
|
+
if (depth === 0) {
|
|
86
|
+
return i;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
i++;
|
|
90
|
+
}
|
|
91
|
+
return -1;
|
|
92
|
+
}
|
|
93
|
+
function listBlocks(raw) {
|
|
94
|
+
const blocks = [];
|
|
95
|
+
HEADER_REGEX.lastIndex = 0;
|
|
96
|
+
let match;
|
|
97
|
+
while ((match = HEADER_REGEX.exec(raw)) !== null) {
|
|
98
|
+
const leadingNewline = match[1] ?? "";
|
|
99
|
+
const header = match[2];
|
|
100
|
+
const open = match[3];
|
|
101
|
+
if (header === void 0 || open !== "{" && open !== "[") {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const headerStart = match.index + leadingNewline.length;
|
|
105
|
+
const openIdx = match.index + match[0].length - 1;
|
|
106
|
+
const closeIdx = findMatchingClose(raw, open, openIdx);
|
|
107
|
+
if (closeIdx === -1) {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
blocks.push({
|
|
111
|
+
header,
|
|
112
|
+
start: headerStart,
|
|
113
|
+
end: closeIdx + 1,
|
|
114
|
+
bodyStart: openIdx + 1,
|
|
115
|
+
bodyEnd: closeIdx,
|
|
116
|
+
open,
|
|
117
|
+
close: open === "{" ? "}" : "]"
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return blocks;
|
|
121
|
+
}
|
|
122
|
+
function parseKeyValueBody(body) {
|
|
123
|
+
const entries = /* @__PURE__ */ new Map();
|
|
124
|
+
for (const lineRaw of body.split("\n")) {
|
|
125
|
+
const line = lineRaw.trim();
|
|
126
|
+
if (line.length === 0 || line.startsWith("//")) {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const colon = line.indexOf(":");
|
|
130
|
+
if (colon === -1) {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const key = line.slice(0, colon).trim();
|
|
134
|
+
const value = line.slice(colon + 1).trim();
|
|
135
|
+
if (key.length > 0) {
|
|
136
|
+
entries.set(key, value);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return entries;
|
|
140
|
+
}
|
|
141
|
+
function parseListBody(body) {
|
|
142
|
+
const items = [];
|
|
143
|
+
for (const lineRaw of body.split("\n")) {
|
|
144
|
+
const line = lineRaw.trim();
|
|
145
|
+
if (line.length === 0 || line.startsWith("//")) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
items.push(line);
|
|
149
|
+
}
|
|
150
|
+
return items;
|
|
151
|
+
}
|
|
152
|
+
function parseBruEnvFile(raw) {
|
|
153
|
+
const blocks = listBlocks(raw);
|
|
154
|
+
const varsBlock = blocks.find((b) => b.header === "vars" && b.open === "{");
|
|
155
|
+
const secretsBlock = blocks.find((b) => b.header === "vars:secret" && b.open === "[");
|
|
156
|
+
const entries = varsBlock ? parseKeyValueBody(raw.slice(varsBlock.bodyStart, varsBlock.bodyEnd)) : /* @__PURE__ */ new Map();
|
|
157
|
+
const secrets = secretsBlock ? parseListBody(raw.slice(secretsBlock.bodyStart, secretsBlock.bodyEnd)) : [];
|
|
158
|
+
return { vars: { entries }, secrets };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// src/bru-writer.ts
|
|
162
|
+
function formatVarsBlock(entries) {
|
|
163
|
+
const lines = [];
|
|
164
|
+
for (const [key, value] of entries) {
|
|
165
|
+
lines.push(` ${key}: ${value}`);
|
|
166
|
+
}
|
|
167
|
+
return lines.join("\n");
|
|
168
|
+
}
|
|
169
|
+
function upsertVars(raw, updates) {
|
|
170
|
+
const blocks = listBlocks(raw);
|
|
171
|
+
const varsBlock = blocks.find((b) => b.header === "vars" && b.open === "{");
|
|
172
|
+
if (!varsBlock) {
|
|
173
|
+
const newBlock = `vars {
|
|
174
|
+
${formatVarsBlock(updates)}
|
|
175
|
+
}
|
|
176
|
+
`;
|
|
177
|
+
const sep2 = raw.length > 0 && !raw.endsWith("\n") ? "\n\n" : raw.length > 0 ? "\n" : "";
|
|
178
|
+
return { content: `${raw}${sep2}${newBlock}`, changed: updates.size > 0 };
|
|
179
|
+
}
|
|
180
|
+
const body = raw.slice(varsBlock.bodyStart, varsBlock.bodyEnd);
|
|
181
|
+
const existing = parseKeyValueBody(body);
|
|
182
|
+
let changed = false;
|
|
183
|
+
for (const [k, v] of updates) {
|
|
184
|
+
if (existing.get(k) !== v) {
|
|
185
|
+
existing.set(k, v);
|
|
186
|
+
changed = true;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (!changed) {
|
|
190
|
+
return { content: raw, changed: false };
|
|
191
|
+
}
|
|
192
|
+
const rebuilt = `
|
|
193
|
+
${formatVarsBlock(existing)}
|
|
194
|
+
`;
|
|
195
|
+
const before = raw.slice(0, varsBlock.bodyStart);
|
|
196
|
+
const after = raw.slice(varsBlock.bodyEnd);
|
|
197
|
+
return { content: `${before}${rebuilt}${after}`, changed: true };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// src/cf-meta.ts
|
|
201
|
+
function readCfMetaFromVars(vars) {
|
|
202
|
+
const region = vars.get("__cf_region");
|
|
203
|
+
const org = vars.get("__cf_org");
|
|
204
|
+
const space = vars.get("__cf_space");
|
|
205
|
+
const app = vars.get("__cf_app");
|
|
206
|
+
if (!region || !org || !space || !app) {
|
|
207
|
+
return void 0;
|
|
208
|
+
}
|
|
209
|
+
return { region, org, space, app };
|
|
210
|
+
}
|
|
211
|
+
function buildCfMetaUpdates(ref, baseUrl) {
|
|
212
|
+
const updates = /* @__PURE__ */ new Map();
|
|
213
|
+
const pairs = [
|
|
214
|
+
["__cf_region", ref.region],
|
|
215
|
+
["__cf_org", ref.org],
|
|
216
|
+
["__cf_space", ref.space],
|
|
217
|
+
["__cf_app", ref.app]
|
|
218
|
+
];
|
|
219
|
+
for (const [k, v] of pairs) {
|
|
220
|
+
updates.set(k, v);
|
|
221
|
+
}
|
|
222
|
+
if (baseUrl !== void 0) {
|
|
223
|
+
updates.set("baseUrl", baseUrl);
|
|
224
|
+
}
|
|
225
|
+
return updates;
|
|
226
|
+
}
|
|
227
|
+
async function readCfMetaFromFile(path) {
|
|
228
|
+
const raw = await readFile2(path, "utf8");
|
|
229
|
+
const parsed = parseBruEnvFile(raw);
|
|
230
|
+
return readCfMetaFromVars(parsed.vars.entries);
|
|
231
|
+
}
|
|
232
|
+
async function writeCfMetaToFile(path, ref, baseUrl) {
|
|
233
|
+
const raw = await readFile2(path, "utf8");
|
|
234
|
+
const updates = buildCfMetaUpdates(ref, baseUrl);
|
|
235
|
+
const { content, changed } = upsertVars(raw, updates);
|
|
236
|
+
if (changed) {
|
|
237
|
+
await writeFile2(path, content, "utf8");
|
|
238
|
+
}
|
|
239
|
+
return changed;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// src/folder-scan.ts
|
|
243
|
+
import { readdir, readFile as readFile3 } from "fs/promises";
|
|
244
|
+
import { join as join2 } from "path";
|
|
245
|
+
async function safeReaddir(path) {
|
|
246
|
+
try {
|
|
247
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
248
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
249
|
+
} catch {
|
|
250
|
+
return [];
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
async function listFiles(path) {
|
|
254
|
+
try {
|
|
255
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
256
|
+
return entries.filter((e) => e.isFile()).map((e) => e.name);
|
|
257
|
+
} catch {
|
|
258
|
+
return [];
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
async function loadEnvFile(path, name) {
|
|
262
|
+
const raw = await readFile3(path, "utf8");
|
|
263
|
+
const parsed = parseBruEnvFile(raw);
|
|
264
|
+
return {
|
|
265
|
+
path,
|
|
266
|
+
name: name.replace(/\.bru$/, ""),
|
|
267
|
+
raw,
|
|
268
|
+
vars: parsed.vars,
|
|
269
|
+
secrets: parsed.secrets
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
async function scanAppEnvironments(appPath) {
|
|
273
|
+
const envDir = join2(appPath, ENVIRONMENTS_DIR);
|
|
274
|
+
const files = await listFiles(envDir);
|
|
275
|
+
const bruFiles = files.filter((f) => f.endsWith(".bru"));
|
|
276
|
+
const loaded = [];
|
|
277
|
+
for (const file of bruFiles) {
|
|
278
|
+
loaded.push(await loadEnvFile(join2(envDir, file), file));
|
|
279
|
+
}
|
|
280
|
+
return loaded;
|
|
281
|
+
}
|
|
282
|
+
async function scanApp(spacePath, name) {
|
|
283
|
+
const appPath = join2(spacePath, name);
|
|
284
|
+
const environments = await scanAppEnvironments(appPath);
|
|
285
|
+
return { path: appPath, name, environments };
|
|
286
|
+
}
|
|
287
|
+
async function scanSpace(orgPath, dirName) {
|
|
288
|
+
const name = parsePrefixedName(dirName, SPACE_FOLDER_PREFIX);
|
|
289
|
+
if (name === void 0) {
|
|
290
|
+
return void 0;
|
|
291
|
+
}
|
|
292
|
+
const spacePath = join2(orgPath, dirName);
|
|
293
|
+
const appDirs = await safeReaddir(spacePath);
|
|
294
|
+
const apps = [];
|
|
295
|
+
for (const appDir of appDirs) {
|
|
296
|
+
apps.push(await scanApp(spacePath, appDir));
|
|
297
|
+
}
|
|
298
|
+
return { path: spacePath, name, apps };
|
|
299
|
+
}
|
|
300
|
+
async function scanOrg(regionPath, dirName) {
|
|
301
|
+
const name = parsePrefixedName(dirName, ORG_FOLDER_PREFIX);
|
|
302
|
+
if (name === void 0) {
|
|
303
|
+
return void 0;
|
|
304
|
+
}
|
|
305
|
+
const orgPath = join2(regionPath, dirName);
|
|
306
|
+
const spaceDirs = await safeReaddir(orgPath);
|
|
307
|
+
const spaces = [];
|
|
308
|
+
for (const spaceDir of spaceDirs) {
|
|
309
|
+
const space = await scanSpace(orgPath, spaceDir);
|
|
310
|
+
if (space) {
|
|
311
|
+
spaces.push(space);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return { path: orgPath, name, spaces };
|
|
315
|
+
}
|
|
316
|
+
async function scanRegion(root, dirName) {
|
|
317
|
+
const key = parsePrefixedName(dirName, REGION_FOLDER_PREFIX);
|
|
318
|
+
if (key === void 0) {
|
|
319
|
+
return void 0;
|
|
320
|
+
}
|
|
321
|
+
const regionPath = join2(root, dirName);
|
|
322
|
+
const orgDirs = await safeReaddir(regionPath);
|
|
323
|
+
const orgs = [];
|
|
324
|
+
for (const orgDir of orgDirs) {
|
|
325
|
+
const org = await scanOrg(regionPath, orgDir);
|
|
326
|
+
if (org) {
|
|
327
|
+
orgs.push(org);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return { path: regionPath, key, orgs };
|
|
331
|
+
}
|
|
332
|
+
async function scanCollection(root) {
|
|
333
|
+
const regionDirs = await safeReaddir(root);
|
|
334
|
+
const regions = [];
|
|
335
|
+
for (const dir of regionDirs) {
|
|
336
|
+
const region = await scanRegion(root, dir);
|
|
337
|
+
if (region) {
|
|
338
|
+
regions.push(region);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return { root, regions };
|
|
342
|
+
}
|
|
343
|
+
function parseShorthandPath(shorthand) {
|
|
344
|
+
const cleaned = shorthand.replace(/^[./]+/, "").replace(/\\/g, "/");
|
|
345
|
+
const segs = cleaned.split("/").filter((s) => s.length > 0);
|
|
346
|
+
if (segs.length < 4) {
|
|
347
|
+
return void 0;
|
|
348
|
+
}
|
|
349
|
+
const [region, org, space, app, ...rest] = segs;
|
|
350
|
+
if (!region || !org || !space || !app) {
|
|
351
|
+
return void 0;
|
|
352
|
+
}
|
|
353
|
+
if (rest.length === 0) {
|
|
354
|
+
return { region, org, space, app };
|
|
355
|
+
}
|
|
356
|
+
const filePath = rest.join("/");
|
|
357
|
+
const last = rest[rest.length - 1] ?? "";
|
|
358
|
+
const environment = last.endsWith(".bru") ? last.replace(/\.bru$/, "") : void 0;
|
|
359
|
+
return environment ? { region, org, space, app, environment, filePath } : { region, org, space, app, filePath };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// src/run.ts
|
|
363
|
+
function defaultSpawnBru(args, env, cwd) {
|
|
364
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
365
|
+
const child = spawn("bru", [...args], { cwd, env, stdio: "pipe" });
|
|
366
|
+
let stdout = "";
|
|
367
|
+
let stderr = "";
|
|
368
|
+
child.stdout.on("data", (chunk) => {
|
|
369
|
+
stdout += chunk.toString("utf8");
|
|
370
|
+
process.stdout.write(chunk);
|
|
371
|
+
});
|
|
372
|
+
child.stderr.on("data", (chunk) => {
|
|
373
|
+
stderr += chunk.toString("utf8");
|
|
374
|
+
process.stderr.write(chunk);
|
|
375
|
+
});
|
|
376
|
+
child.on("error", rejectPromise);
|
|
377
|
+
child.on("close", (code) => {
|
|
378
|
+
resolvePromise({ code: code ?? 0, stdout, stderr });
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
async function exists(path) {
|
|
383
|
+
try {
|
|
384
|
+
await stat(path);
|
|
385
|
+
return true;
|
|
386
|
+
} catch {
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
async function resolveTarget(root, target) {
|
|
391
|
+
const direct = isAbsolute(target) ? target : resolve(process.cwd(), target);
|
|
392
|
+
if (await exists(direct)) {
|
|
393
|
+
return { filePath: direct, shorthand: void 0 };
|
|
394
|
+
}
|
|
395
|
+
const shorthand = parseShorthandPath(target);
|
|
396
|
+
if (!shorthand) {
|
|
397
|
+
throw new Error(`Target not found: ${target}`);
|
|
398
|
+
}
|
|
399
|
+
const { region, org, space, app, filePath } = shorthand;
|
|
400
|
+
const appDir = join3(
|
|
401
|
+
root,
|
|
402
|
+
regionFolderName(region),
|
|
403
|
+
orgFolderName(org),
|
|
404
|
+
spaceFolderName(space),
|
|
405
|
+
app
|
|
406
|
+
);
|
|
407
|
+
if (!filePath) {
|
|
408
|
+
return { filePath: appDir, shorthand };
|
|
409
|
+
}
|
|
410
|
+
const candidate = join3(appDir, filePath);
|
|
411
|
+
if (await exists(candidate)) {
|
|
412
|
+
return { filePath: candidate, shorthand };
|
|
413
|
+
}
|
|
414
|
+
const withExt = candidate.endsWith(".bru") ? candidate : `${candidate}.bru`;
|
|
415
|
+
if (await exists(withExt)) {
|
|
416
|
+
return { filePath: withExt, shorthand };
|
|
417
|
+
}
|
|
418
|
+
throw new Error(`File not found: ${candidate}`);
|
|
419
|
+
}
|
|
420
|
+
async function chooseEnvironmentFile(appDir, environment) {
|
|
421
|
+
if (environment) {
|
|
422
|
+
const envFile = join3(appDir, ENVIRONMENTS_DIR, `${environment}.bru`);
|
|
423
|
+
if (!await exists(envFile)) {
|
|
424
|
+
throw new Error(`Environment file not found: ${envFile}`);
|
|
425
|
+
}
|
|
426
|
+
return { envFile, environment };
|
|
427
|
+
}
|
|
428
|
+
const collection = await scanCollection(resolve(appDir, "..", "..", "..", ".."));
|
|
429
|
+
for (const region of collection.regions) {
|
|
430
|
+
for (const org of region.orgs) {
|
|
431
|
+
for (const space of org.spaces) {
|
|
432
|
+
for (const app of space.apps) {
|
|
433
|
+
if (app.path === appDir && app.environments.length > 0) {
|
|
434
|
+
const first = app.environments[0];
|
|
435
|
+
if (first) {
|
|
436
|
+
return { envFile: first.path, environment: first.name };
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
throw new Error(`No environment files found under ${appDir}/${ENVIRONMENTS_DIR}`);
|
|
444
|
+
}
|
|
445
|
+
function findAppDirFromFile(filePath, root) {
|
|
446
|
+
const rel = relative(root, filePath).split(sep);
|
|
447
|
+
if (rel.length < 4) {
|
|
448
|
+
throw new Error(`File is not inside a CF-structured bruno collection: ${filePath}`);
|
|
449
|
+
}
|
|
450
|
+
const [regionDir, orgDir, spaceDir, appDir] = rel;
|
|
451
|
+
if (!regionDir || !orgDir || !spaceDir || !appDir) {
|
|
452
|
+
throw new Error(`File is not inside a CF-structured bruno collection: ${filePath}`);
|
|
453
|
+
}
|
|
454
|
+
return join3(root, regionDir, orgDir, spaceDir, appDir);
|
|
455
|
+
}
|
|
456
|
+
async function buildRunPlan(options) {
|
|
457
|
+
const { filePath } = await resolveTarget(options.root, options.target);
|
|
458
|
+
const stats = await stat(filePath);
|
|
459
|
+
let appDir;
|
|
460
|
+
let requestFile;
|
|
461
|
+
if (stats.isDirectory()) {
|
|
462
|
+
appDir = filePath;
|
|
463
|
+
requestFile = void 0;
|
|
464
|
+
} else {
|
|
465
|
+
appDir = findAppDirFromFile(filePath, options.root);
|
|
466
|
+
requestFile = filePath;
|
|
467
|
+
}
|
|
468
|
+
const { envFile, environment } = await chooseEnvironmentFile(appDir, options.environment);
|
|
469
|
+
const meta = await readCfMetaFromFile(envFile);
|
|
470
|
+
if (!meta) {
|
|
471
|
+
throw new Error(
|
|
472
|
+
`Missing __cf_region/__cf_org/__cf_space/__cf_app in ${envFile}. Run \`saptools-bruno setup-app\` first.`
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
const getToken = options.getTokenCached ?? getTokenCachedApi;
|
|
476
|
+
const token = await getToken(meta);
|
|
477
|
+
const bruArgs = ["run"];
|
|
478
|
+
if (requestFile) {
|
|
479
|
+
bruArgs.push(relative(appDir, requestFile) || ".");
|
|
480
|
+
}
|
|
481
|
+
bruArgs.push("--env", environment, "--env-var", `accessToken=${token}`);
|
|
482
|
+
if (options.extraArgs) {
|
|
483
|
+
bruArgs.push(...options.extraArgs);
|
|
484
|
+
}
|
|
485
|
+
return {
|
|
486
|
+
filePath,
|
|
487
|
+
environment,
|
|
488
|
+
envFile,
|
|
489
|
+
meta,
|
|
490
|
+
token,
|
|
491
|
+
bruArgs,
|
|
492
|
+
cwd: appDir
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
async function runBruno(options) {
|
|
496
|
+
const plan = await buildRunPlan(options);
|
|
497
|
+
const spawnFn = options.spawnBru ?? defaultSpawnBru;
|
|
498
|
+
const env = { ...process.env, SAPTOOLS_ACCESS_TOKEN: plan.token };
|
|
499
|
+
options.log?.(`\u25B6 bru ${plan.bruArgs.join(" ")} (cwd=${plan.cwd})`);
|
|
500
|
+
const result = await spawnFn(plan.bruArgs, env, plan.cwd);
|
|
501
|
+
return { ...plan, ...result };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// src/setup-app.ts
|
|
505
|
+
import { mkdir as mkdir2, readdir as readdir2, writeFile as writeFile3 } from "fs/promises";
|
|
506
|
+
import { join as join4 } from "path";
|
|
507
|
+
|
|
508
|
+
// src/cf-info.ts
|
|
509
|
+
import {
|
|
510
|
+
getRegionView as getRegionViewApi,
|
|
511
|
+
readRegionsView,
|
|
512
|
+
readRegionView,
|
|
513
|
+
readStructureView,
|
|
514
|
+
REGION_KEYS
|
|
515
|
+
} from "@saptools/cf-sync";
|
|
516
|
+
var defaultCfInfoDeps = {
|
|
517
|
+
readStructureView,
|
|
518
|
+
readRegionsView,
|
|
519
|
+
readRegionView,
|
|
520
|
+
getRegionView: getRegionViewApi
|
|
521
|
+
};
|
|
522
|
+
function isValidRegionKey(value) {
|
|
523
|
+
return REGION_KEYS.includes(value);
|
|
524
|
+
}
|
|
525
|
+
async function getStructureSnapshot(deps = defaultCfInfoDeps) {
|
|
526
|
+
const view = await deps.readStructureView();
|
|
527
|
+
if (!view) {
|
|
528
|
+
return {
|
|
529
|
+
source: "empty",
|
|
530
|
+
structure: void 0,
|
|
531
|
+
stale: true,
|
|
532
|
+
message: "No CF structure cached. Run `cf-sync sync` first."
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
const stale = view.source === "runtime" && view.metadata?.status === "running";
|
|
536
|
+
return {
|
|
537
|
+
source: view.source,
|
|
538
|
+
structure: view.structure,
|
|
539
|
+
stale,
|
|
540
|
+
message: stale ? "A CF sync is still running \u2014 showing partial data." : void 0
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
async function listRegionsWithContent(deps = defaultCfInfoDeps) {
|
|
544
|
+
const snapshot = await getStructureSnapshot(deps);
|
|
545
|
+
if (!snapshot.structure) {
|
|
546
|
+
return [];
|
|
547
|
+
}
|
|
548
|
+
return snapshot.structure.regions.filter((r) => r.accessible && r.orgs.length > 0).map((r) => ({ key: r.key, label: r.label, orgCount: r.orgs.length }));
|
|
549
|
+
}
|
|
550
|
+
async function getRegion(key, deps = defaultCfInfoDeps) {
|
|
551
|
+
const view = await deps.readRegionView(key);
|
|
552
|
+
return view?.region;
|
|
553
|
+
}
|
|
554
|
+
function findOrg(region, orgName) {
|
|
555
|
+
return region.orgs.find((o) => o.name === orgName);
|
|
556
|
+
}
|
|
557
|
+
function findSpace(org, spaceName) {
|
|
558
|
+
return org.spaces.find((s) => s.name === spaceName);
|
|
559
|
+
}
|
|
560
|
+
function findApp(space, appName) {
|
|
561
|
+
return space.apps.find((a) => a.name === appName);
|
|
562
|
+
}
|
|
563
|
+
async function resolveRef(ref, deps = defaultCfInfoDeps) {
|
|
564
|
+
const region = await getRegion(ref.region, deps);
|
|
565
|
+
if (!region) {
|
|
566
|
+
return void 0;
|
|
567
|
+
}
|
|
568
|
+
const org = findOrg(region, ref.org);
|
|
569
|
+
if (!org) {
|
|
570
|
+
return void 0;
|
|
571
|
+
}
|
|
572
|
+
const space = findSpace(org, ref.space);
|
|
573
|
+
if (!space) {
|
|
574
|
+
return void 0;
|
|
575
|
+
}
|
|
576
|
+
const app = findApp(space, ref.app);
|
|
577
|
+
if (!app) {
|
|
578
|
+
return void 0;
|
|
579
|
+
}
|
|
580
|
+
return { region, org, space, app };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// src/setup-app.ts
|
|
584
|
+
var COMMON_ENVIRONMENTS = ["local", "dev", "staging", "prod"];
|
|
585
|
+
function emptyEnvContent(envName, ref) {
|
|
586
|
+
const lines = [
|
|
587
|
+
"vars {",
|
|
588
|
+
` __cf_region: ${ref.region}`,
|
|
589
|
+
` __cf_org: ${ref.org}`,
|
|
590
|
+
` __cf_space: ${ref.space}`,
|
|
591
|
+
` __cf_app: ${ref.app}`,
|
|
592
|
+
` environment: ${envName}`,
|
|
593
|
+
" baseUrl: ",
|
|
594
|
+
"}",
|
|
595
|
+
""
|
|
596
|
+
];
|
|
597
|
+
return lines.join("\n");
|
|
598
|
+
}
|
|
599
|
+
async function ensureEnvFile(appPath, envName, ref) {
|
|
600
|
+
const envDir = join4(appPath, ENVIRONMENTS_DIR);
|
|
601
|
+
await mkdir2(envDir, { recursive: true });
|
|
602
|
+
const filePath = join4(envDir, `${envName}.bru`);
|
|
603
|
+
try {
|
|
604
|
+
await writeFile3(filePath, emptyEnvContent(envName, ref), { encoding: "utf8", flag: "wx" });
|
|
605
|
+
} catch (err) {
|
|
606
|
+
if (err.code !== "EEXIST") {
|
|
607
|
+
throw err;
|
|
608
|
+
}
|
|
609
|
+
await writeCfMetaToFile(filePath, ref);
|
|
610
|
+
}
|
|
611
|
+
return filePath;
|
|
612
|
+
}
|
|
613
|
+
function pickRegion(regions) {
|
|
614
|
+
return regions.map((r) => ({ value: r.key, name: `${r.key} \u2014 ${r.label} (${r.orgCount.toString()} org${r.orgCount === 1 ? "" : "s"})` }));
|
|
615
|
+
}
|
|
616
|
+
function pickOrg(region) {
|
|
617
|
+
return region.orgs.map((o) => ({ value: o.name, name: `${o.name} (${o.spaces.length.toString()} space${o.spaces.length === 1 ? "" : "s"})` }));
|
|
618
|
+
}
|
|
619
|
+
function pickSpace(org) {
|
|
620
|
+
return org.spaces.map((s) => ({ value: s.name, name: `${s.name} (${s.apps.length.toString()} app${s.apps.length === 1 ? "" : "s"})` }));
|
|
621
|
+
}
|
|
622
|
+
function pickApp(space) {
|
|
623
|
+
return space.apps.map((a) => ({ value: a.name, name: a.name }));
|
|
624
|
+
}
|
|
625
|
+
async function setupApp(options) {
|
|
626
|
+
const deps = options.deps ?? defaultCfInfoDeps;
|
|
627
|
+
const log = options.log ?? (() => void 0);
|
|
628
|
+
const regions = await listRegionsWithContent(deps);
|
|
629
|
+
if (regions.length === 0) {
|
|
630
|
+
throw new Error(
|
|
631
|
+
"No CF regions with orgs are cached. Run `cf-sync sync` first, or pass SAP_EMAIL/SAP_PASSWORD to refresh."
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
const regionKey = await options.prompts.selectRegion(pickRegion(regions));
|
|
635
|
+
const regionView = await deps.readRegionView(regionKey);
|
|
636
|
+
if (!regionView) {
|
|
637
|
+
throw new Error(`Region ${regionKey} is not cached. Run \`cf-sync sync\` or \`cf-sync region ${regionKey}\`.`);
|
|
638
|
+
}
|
|
639
|
+
const region = regionView.region;
|
|
640
|
+
if (region.orgs.length === 0) {
|
|
641
|
+
throw new Error(`Region ${regionKey} has no accessible orgs.`);
|
|
642
|
+
}
|
|
643
|
+
const orgName = await options.prompts.selectOrg(pickOrg(region));
|
|
644
|
+
const org = region.orgs.find((o) => o.name === orgName);
|
|
645
|
+
if (!org) {
|
|
646
|
+
throw new Error(`Org ${orgName} not found in region ${regionKey}`);
|
|
647
|
+
}
|
|
648
|
+
if (org.spaces.length === 0) {
|
|
649
|
+
throw new Error(`Org ${orgName} has no spaces.`);
|
|
650
|
+
}
|
|
651
|
+
const spaceName = await options.prompts.selectSpace(pickSpace(org));
|
|
652
|
+
const space = org.spaces.find((s) => s.name === spaceName);
|
|
653
|
+
if (!space) {
|
|
654
|
+
throw new Error(`Space ${spaceName} not found in org ${orgName}`);
|
|
655
|
+
}
|
|
656
|
+
if (space.apps.length === 0) {
|
|
657
|
+
throw new Error(`Space ${spaceName} has no apps.`);
|
|
658
|
+
}
|
|
659
|
+
const appName = await options.prompts.selectApp(pickApp(space));
|
|
660
|
+
const ref = { region: regionKey, org: orgName, space: spaceName, app: appName };
|
|
661
|
+
const appPath = join4(
|
|
662
|
+
options.root,
|
|
663
|
+
regionFolderName(regionKey),
|
|
664
|
+
orgFolderName(orgName),
|
|
665
|
+
spaceFolderName(spaceName),
|
|
666
|
+
appName
|
|
667
|
+
);
|
|
668
|
+
const confirmed = await options.prompts.confirmCreate(appPath);
|
|
669
|
+
if (!confirmed) {
|
|
670
|
+
return { ref, appPath, environments: [], created: false };
|
|
671
|
+
}
|
|
672
|
+
await mkdir2(appPath, { recursive: true });
|
|
673
|
+
const existingEnvs = await listExistingEnvs(appPath);
|
|
674
|
+
const common = [...COMMON_ENVIRONMENTS];
|
|
675
|
+
const selected = await options.prompts.selectEnvironments({ common, existing: existingEnvs });
|
|
676
|
+
const custom = await options.prompts.inputCustomEnvName();
|
|
677
|
+
const merged = [];
|
|
678
|
+
for (const name of [...selected, ...custom ? [custom] : []]) {
|
|
679
|
+
const trimmed = name.trim();
|
|
680
|
+
if (trimmed.length > 0 && !merged.includes(trimmed)) {
|
|
681
|
+
merged.push(trimmed);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
if (merged.length === 0) {
|
|
685
|
+
throw new Error("At least one environment is required.");
|
|
686
|
+
}
|
|
687
|
+
const created = [];
|
|
688
|
+
for (const envName of merged) {
|
|
689
|
+
const path = await ensureEnvFile(appPath, envName, ref);
|
|
690
|
+
created.push(path);
|
|
691
|
+
log(`\u2022 ${path}`);
|
|
692
|
+
}
|
|
693
|
+
return { ref, appPath, environments: created, created: true };
|
|
694
|
+
}
|
|
695
|
+
async function listExistingEnvs(appPath) {
|
|
696
|
+
try {
|
|
697
|
+
const entries = await readdir2(join4(appPath, ENVIRONMENTS_DIR), { withFileTypes: true });
|
|
698
|
+
return entries.filter((e) => e.isFile() && e.name.endsWith(".bru")).map((e) => e.name.replace(/\.bru$/, ""));
|
|
699
|
+
} catch {
|
|
700
|
+
return [];
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// src/use.ts
|
|
705
|
+
function parseContextShorthand(shorthand) {
|
|
706
|
+
const segs = shorthand.split("/").filter((s) => s.length > 0);
|
|
707
|
+
if (segs.length !== 4) {
|
|
708
|
+
return void 0;
|
|
709
|
+
}
|
|
710
|
+
const [region, org, space, app] = segs;
|
|
711
|
+
if (!region || !org || !space || !app) {
|
|
712
|
+
return void 0;
|
|
713
|
+
}
|
|
714
|
+
return { region, org, space, app };
|
|
715
|
+
}
|
|
716
|
+
async function useContext(options) {
|
|
717
|
+
const parsed = parseContextShorthand(options.shorthand);
|
|
718
|
+
if (!parsed) {
|
|
719
|
+
throw new Error(
|
|
720
|
+
`Invalid context shorthand: ${options.shorthand}. Expected <region>/<org>/<space>/<app>.`
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
if (!isValidRegionKey(parsed.region)) {
|
|
724
|
+
throw new Error(`Unknown region key: ${parsed.region}`);
|
|
725
|
+
}
|
|
726
|
+
if (options.verify !== false) {
|
|
727
|
+
const resolved = await resolveRef({ ...parsed, region: parsed.region }, options.deps);
|
|
728
|
+
if (!resolved) {
|
|
729
|
+
throw new Error(
|
|
730
|
+
`Could not verify ${options.shorthand} against the cached CF structure. Run \`cf-sync sync\` first.`
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
return await writeContext(parsed);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// src/cli.ts
|
|
738
|
+
function resolveRoot(explicit) {
|
|
739
|
+
if (explicit) {
|
|
740
|
+
return explicit;
|
|
741
|
+
}
|
|
742
|
+
if (process2.env["SAPTOOLS_BRUNO_ROOT"]) {
|
|
743
|
+
return process2.env["SAPTOOLS_BRUNO_ROOT"];
|
|
744
|
+
}
|
|
745
|
+
return process2.cwd();
|
|
746
|
+
}
|
|
747
|
+
async function main(argv) {
|
|
748
|
+
const program = new Command();
|
|
749
|
+
program.name("saptools-bruno").description("Smart runner for Bruno with CF-aware env metadata and automatic token injection").option("--root <dir>", "Root directory of the bruno collection (default: cwd)");
|
|
750
|
+
program.command("setup-app").description("Interactively scaffold a bruno app folder and seed __cf_* variables").action(async () => {
|
|
751
|
+
const root = resolveRoot(program.opts().root);
|
|
752
|
+
const result = await setupApp({
|
|
753
|
+
root,
|
|
754
|
+
prompts: {
|
|
755
|
+
selectRegion: async (choices) => await select({ message: "Select region", choices: [...choices] }),
|
|
756
|
+
selectOrg: async (choices) => await select({ message: "Select org", choices: [...choices] }),
|
|
757
|
+
selectSpace: async (choices) => await select({ message: "Select space", choices: [...choices] }),
|
|
758
|
+
selectApp: async (choices) => await select({ message: "Select app", choices: [...choices] }),
|
|
759
|
+
confirmCreate: async (path) => await confirm({ message: `Create ${path}?`, default: true }),
|
|
760
|
+
selectEnvironments: async ({ common, existing }) => {
|
|
761
|
+
const seen = /* @__PURE__ */ new Set();
|
|
762
|
+
const all = [...common, ...existing].filter((name) => {
|
|
763
|
+
if (seen.has(name)) {
|
|
764
|
+
return false;
|
|
765
|
+
}
|
|
766
|
+
seen.add(name);
|
|
767
|
+
return true;
|
|
768
|
+
});
|
|
769
|
+
return await checkbox({
|
|
770
|
+
message: "Environments to create (space to toggle, enter to confirm)",
|
|
771
|
+
choices: all.map((name) => ({
|
|
772
|
+
name,
|
|
773
|
+
value: name,
|
|
774
|
+
checked: existing.includes(name)
|
|
775
|
+
}))
|
|
776
|
+
});
|
|
777
|
+
},
|
|
778
|
+
inputCustomEnvName: async () => {
|
|
779
|
+
const raw = await input({
|
|
780
|
+
message: "Custom environment name (leave empty to skip)",
|
|
781
|
+
default: "",
|
|
782
|
+
validate: (v) => {
|
|
783
|
+
const t = v.trim();
|
|
784
|
+
if (t.length === 0) {
|
|
785
|
+
return true;
|
|
786
|
+
}
|
|
787
|
+
return /^[A-Za-z0-9._-]+$/.test(t) ? true : "Only letters, digits, dot, underscore, and dash are allowed.";
|
|
788
|
+
}
|
|
789
|
+
});
|
|
790
|
+
const trimmed = raw.trim();
|
|
791
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
792
|
+
}
|
|
793
|
+
},
|
|
794
|
+
log: (msg) => {
|
|
795
|
+
process2.stdout.write(`${msg}
|
|
796
|
+
`);
|
|
797
|
+
}
|
|
798
|
+
});
|
|
799
|
+
if (!result.created) {
|
|
800
|
+
process2.stdout.write("Aborted.\n");
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
process2.stdout.write(`\u2714 App folder ready at ${result.appPath}
|
|
804
|
+
`);
|
|
805
|
+
});
|
|
806
|
+
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(
|
|
807
|
+
async (target, opts) => {
|
|
808
|
+
const root = resolveRoot(program.opts().root);
|
|
809
|
+
let effectiveTarget = target;
|
|
810
|
+
if (!effectiveTarget) {
|
|
811
|
+
const ctx = await readContext();
|
|
812
|
+
if (!ctx) {
|
|
813
|
+
throw new Error(
|
|
814
|
+
"No target specified and no default context is set. Run `saptools-bruno use <region/org/space/app>` first."
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
effectiveTarget = `${ctx.region}/${ctx.org}/${ctx.space}/${ctx.app}`;
|
|
818
|
+
}
|
|
819
|
+
const result = await runBruno({
|
|
820
|
+
root,
|
|
821
|
+
target: effectiveTarget,
|
|
822
|
+
...opts.env ? { environment: opts.env } : {},
|
|
823
|
+
log: (msg) => {
|
|
824
|
+
process2.stdout.write(`${msg}
|
|
825
|
+
`);
|
|
826
|
+
}
|
|
827
|
+
});
|
|
828
|
+
process2.exit(result.code);
|
|
829
|
+
}
|
|
830
|
+
);
|
|
831
|
+
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) => {
|
|
832
|
+
const ctx = await useContext({
|
|
833
|
+
shorthand,
|
|
834
|
+
verify: opts.verify !== false
|
|
835
|
+
});
|
|
836
|
+
process2.stdout.write(`\u2714 Default context set to ${ctx.region}/${ctx.org}/${ctx.space}/${ctx.app}
|
|
837
|
+
`);
|
|
838
|
+
});
|
|
839
|
+
await program.parseAsync([...argv]);
|
|
840
|
+
}
|
|
841
|
+
try {
|
|
842
|
+
await main(process2.argv);
|
|
843
|
+
} catch (err) {
|
|
844
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
845
|
+
process2.stderr.write(`Error: ${msg}
|
|
846
|
+
`);
|
|
847
|
+
process2.exit(1);
|
|
848
|
+
}
|
|
849
|
+
export {
|
|
850
|
+
main
|
|
851
|
+
};
|
|
852
|
+
//# sourceMappingURL=cli.js.map
|