azure-pipelines-tui 0.5.1 → 0.5.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/README.md CHANGED
@@ -1,6 +1,10 @@
1
1
  # Azure Pipelines TUI
2
2
 
3
- A terminal UI for live-following Azure DevOps pipeline runs, with streaming logs via SignalR.
3
+ A terminal UI for Azure DevOps pipelines. Two standout features:
4
+
5
+ ### Live pipeline run viewer
6
+
7
+ Follow a running or completed pipeline build in real time. A stage/job tree on the left streams log output on the right via SignalR — no browser required.
4
8
 
5
9
  ```
6
10
  ┌ Pipeline ──────────────┐┌ Logs — Initialize job ───────────────────────────────┐
@@ -12,6 +16,24 @@ A terminal UI for live-following Azure DevOps pipeline runs, with streaming logs
12
16
  └────────────────────────┘└──────────────────────────────────────────────────────┘
13
17
  ```
14
18
 
19
+ ### Stages dashboard for GitOps
20
+
21
+ Per-branch overview of Plan/Apply stage pairs across recent runs. Shows the current deployment state for every branch at a glance — and when a run failed, also shows the last successful result alongside it.
22
+
23
+ ```
24
+ Stage / Branch Plan Apply
25
+ ┌ Stages: Deploy-to-prod (48 runs) ────────────────────────────────────────────┐
26
+ │ Deploy │
27
+ │ main ✓ 5m ✓ 2h │
28
+ │ feature/PLAT-123 ✗ 30m (✓2d) - │
29
+ │ release/v1.2 ✓ 1h ✓ 3h │
30
+ │ │
31
+ │ Infra │
32
+ │ main ✓ 1h ✗ 3h (✓1d) │
33
+ │ feature/PLAT-456 ○ wait - │
34
+ └───────────────────────────────────────────────────────────────────────────────┘
35
+ ```
36
+
15
37
  ## Requirements
16
38
 
17
39
  - Node.js 18+
@@ -40,6 +62,37 @@ npx azure-pipelines-tui <build-url> # Single pipeline run (ful
40
62
  | `r` | Retry/restart selected stage |
41
63
  | `q` `Ctrl+C` | Quit |
42
64
 
65
+ ## Stages dashboard
66
+
67
+ Status icons:
68
+
69
+ | Icon | Meaning |
70
+ |------|---------|
71
+ | `✓ 5m` | Succeeded, finished 5 minutes ago |
72
+ | `✗ 30m (✓2d)` | Failed, last success was 2 days ago |
73
+ | `▶ –` | In progress |
74
+ | `⚠ 5m` | Succeeded with warnings |
75
+ | `⊘ –` | Skipped / canceled, no prior run |
76
+ | `○ –` | Pending |
77
+ | `-` | Stage did not run |
78
+
79
+ The `*` suffix (e.g. `✓ 2d *`) means the most recent run was skipped or canceled — the cell shows the last active run instead.
80
+
81
+ ### Key bindings
82
+
83
+ | Key | Action |
84
+ |-----|--------|
85
+ | `↑` `↓` | Navigate rows |
86
+ | `Enter` | Open the run in the browser |
87
+ | `r` | Refresh data |
88
+ | `b` | Open pipeline summary in browser |
89
+ | `p` | Go to pipelines list |
90
+ | `e` | Go to environments overview |
91
+ | `Esc` | Back |
92
+ | `q` | Quit |
93
+
94
+ See [docs/stages-dashboard-design.md](docs/stages-dashboard-design.md) for the full design.
95
+
43
96
  ## How to run locally
44
97
 
45
98
  ```bash
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const api_js_1 = require("../lib/api.js");
4
+ async function main() {
5
+ const token = await (0, api_js_1.getToken)();
6
+ console.log("Token OK\n");
7
+ console.log("Fetching orgs…");
8
+ const orgs = await (0, api_js_1.fetchOrgs)(token);
9
+ console.log("Orgs:", orgs.map(o => o.accountName));
10
+ if (orgs.length > 0) {
11
+ const first = orgs[0].accountName;
12
+ console.log(`\nFetching projects for ${first}…`);
13
+ const projects = await (0, api_js_1.fetchProjects)(first, token);
14
+ console.log("Projects:", projects.map(p => p.name));
15
+ }
16
+ }
17
+ main().catch(e => { console.error("ERROR:", e.message); process.exit(1); });
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /**
4
+ * debugRetry.ts — standalone debug script for stage retry
5
+ *
6
+ * Usage:
7
+ * npx tsx debugRetry.ts <org>/<project> <buildId> [stageRef]
8
+ * npx tsx debugRetry.ts IGH-Solution/IGH-Platform-Azure 12345
9
+ * npx tsx debugRetry.ts IGH-Solution/IGH-Platform-Azure 12345 MyStage
10
+ *
11
+ * Without stageRef: lists all stages with state/result.
12
+ * With stageRef: tries all state values (1 and 2) and forceRetryAllJobs combos.
13
+ */
14
+ var __importDefault = (this && this.__importDefault) || function (mod) {
15
+ return (mod && mod.__esModule) ? mod : { "default": mod };
16
+ };
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ const https_1 = __importDefault(require("https"));
19
+ const child_process_1 = require("child_process");
20
+ const ADO_RESOURCE = "499b84ac-1321-427f-aa17-267ca6975798";
21
+ const API_VER = "api-version=7.1";
22
+ const [orgProject, buildId, stageRef] = process.argv.slice(2);
23
+ if (!orgProject || !buildId) {
24
+ console.error("Usage: npx tsx debugRetry.ts <org>/<project> <buildId> [stageRef]");
25
+ process.exit(1);
26
+ }
27
+ const [org, ...rest] = orgProject.split("/");
28
+ const project = rest.join("/");
29
+ const ADO_BASE = `https://dev.azure.com/${encodeURIComponent(org)}/${encodeURIComponent(project)}/_apis/build/builds/${buildId}`;
30
+ function getToken() {
31
+ const raw = (0, child_process_1.execSync)(`az account get-access-token --resource ${ADO_RESOURCE} --output json`, { encoding: "utf8", env: { ...process.env, AZURE_CONFIG_DIR: process.env["AZURE_CONFIG_DIR"] } });
32
+ return JSON.parse(raw).accessToken;
33
+ }
34
+ function httpGet(url, token) {
35
+ return new Promise((resolve, reject) => {
36
+ const u = new URL(url);
37
+ https_1.default.get({ hostname: u.hostname, path: u.pathname + u.search,
38
+ headers: { Authorization: `Bearer ${token}`, Accept: "application/json" } }, res => {
39
+ let data = "";
40
+ res.on("data", (c) => (data += c));
41
+ res.on("end", () => {
42
+ console.log(` GET ${url} → ${res.statusCode}`);
43
+ if ((res.statusCode ?? 0) >= 400)
44
+ return reject(new Error(`HTTP ${res.statusCode}: ${data}`));
45
+ resolve(JSON.parse(data));
46
+ });
47
+ }).on("error", reject);
48
+ });
49
+ }
50
+ function httpPatch(url, token, body) {
51
+ return new Promise((resolve) => {
52
+ const u = new URL(url);
53
+ const payload = JSON.stringify(body);
54
+ console.log(` PATCH ${url}`);
55
+ console.log(` Body: ${payload}`);
56
+ const req = https_1.default.request({
57
+ hostname: u.hostname,
58
+ path: u.pathname + u.search,
59
+ method: "PATCH",
60
+ headers: {
61
+ Authorization: `Bearer ${token}`,
62
+ "Content-Type": "application/json",
63
+ "Content-Length": Buffer.byteLength(payload),
64
+ },
65
+ }, res => {
66
+ let data = "";
67
+ res.on("data", (c) => (data += c));
68
+ res.on("end", () => {
69
+ let parsed;
70
+ try {
71
+ parsed = JSON.parse(data);
72
+ }
73
+ catch { /* raw */ }
74
+ resolve({ status: res.statusCode ?? 0, body: data, parsed });
75
+ });
76
+ });
77
+ req.on("error", (e) => resolve({ status: 0, body: String(e) }));
78
+ req.write(payload);
79
+ req.end();
80
+ });
81
+ }
82
+ async function main() {
83
+ console.log(`\nOrg: ${org} Project: ${project} BuildId: ${buildId}`);
84
+ console.log("Fetching token…");
85
+ const token = getToken();
86
+ console.log("Token OK\n");
87
+ // Fetch timeline
88
+ const timeline = await httpGet(`${ADO_BASE}/timeline?${API_VER}`, token);
89
+ const stages = timeline.records
90
+ .filter(r => r.type === "Stage")
91
+ .sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
92
+ console.log("\n── Stages ────────────────────────────────────────────────────────");
93
+ for (const s of stages) {
94
+ const ref = s.identifier ?? s.name;
95
+ console.log(` [order=${s.order ?? "?"}] ${s.name.padEnd(40)} state=${s.state.padEnd(12)} result=${s.result ?? "(none)".padEnd(20)} ref="${ref}"`);
96
+ }
97
+ console.log("");
98
+ if (!stageRef) {
99
+ console.log("Pass a stageRef as third argument to test retry.");
100
+ return;
101
+ }
102
+ const target = stages.find(s => (s.identifier ?? s.name) === stageRef);
103
+ if (!target) {
104
+ console.warn(`Stage "${stageRef}" not found. Check spelling against the list above.`);
105
+ return;
106
+ }
107
+ console.log(`\n── Testing retry on stage "${target.name}" (ref: ${stageRef}) ────`);
108
+ console.log(` Current state: ${target.state}, result: ${target.result ?? "(none)"}\n`);
109
+ const url = `${ADO_BASE}/stages/${encodeURIComponent(stageRef)}?${API_VER}`;
110
+ const combos = [
111
+ { state: 1, forceRetryAllJobs: true, retryDependencies: true },
112
+ { state: 1, forceRetryAllJobs: true, retryDependencies: false },
113
+ { state: 1, forceRetryAllJobs: false, retryDependencies: true },
114
+ { state: 1, forceRetryAllJobs: false, retryDependencies: false },
115
+ { state: 2, forceRetryAllJobs: true, retryDependencies: true },
116
+ { state: 2, forceRetryAllJobs: true, retryDependencies: false },
117
+ { state: 2, forceRetryAllJobs: false, retryDependencies: true },
118
+ { state: 2, forceRetryAllJobs: false, retryDependencies: false },
119
+ ];
120
+ for (const combo of combos) {
121
+ console.log(`\n[state=${combo.state}, forceRetryAllJobs=${combo.forceRetryAllJobs}, retryDependencies=${combo.retryDependencies}]`);
122
+ const result = await httpPatch(url, token, combo);
123
+ console.log(` Response ${result.status}: ${result.body.slice(0, 300)}`);
124
+ if (result.status < 400) {
125
+ console.log(" ✓ SUCCESS — this combo works!");
126
+ await pollStageState(token, stageRef);
127
+ break;
128
+ }
129
+ }
130
+ }
131
+ async function pollStageState(token, ref, timeoutMs = 60_000, intervalMs = 2_000) {
132
+ console.log(`\n── Polling stage state (timeout ${timeoutMs / 1000}s) ────────────────`);
133
+ const deadline = Date.now() + timeoutMs;
134
+ while (Date.now() < deadline) {
135
+ const timeline = await httpGet(`${ADO_BASE}/timeline?${API_VER}`, token);
136
+ const stage = timeline.records.find(r => r.type === "Stage" && (r.identifier ?? r.name) === ref);
137
+ if (!stage) {
138
+ console.log(" Stage not found in timeline");
139
+ break;
140
+ }
141
+ const ts = new Date().toISOString().slice(11, 19);
142
+ console.log(` [${ts}] state=${stage.state} result=${stage.result ?? "(none)"}`);
143
+ if (stage.state === "inProgress" || stage.state === "pending") {
144
+ console.log(" ✓ Stage transitioned — retry confirmed working.");
145
+ break;
146
+ }
147
+ await new Promise(r => setTimeout(r, intervalMs));
148
+ }
149
+ }
150
+ main().catch(e => { console.error(e); process.exit(1); });
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ // Debug script: connect to Azure DevOps SignalR and dump all messages
4
+ //
5
+ // Usage:
6
+ // tsx debugSignalR.ts https://dev.azure.com/ORG/PROJECT <buildId>
7
+ // tsx debugSignalR.ts https://dev.azure.com/ORG/PROJECT/_build/results?buildId=<id>
8
+ // tsx debugSignalR.ts https://dev.azure.com/ORG PROJECT <buildId>
9
+ // tsx debugSignalR.ts ORG/PROJECT <buildId>
10
+ var __importDefault = (this && this.__importDefault) || function (mod) {
11
+ return (mod && mod.__esModule) ? mod : { "default": mod };
12
+ };
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ const https_1 = __importDefault(require("https"));
15
+ const ws_1 = __importDefault(require("ws"));
16
+ const child_process_1 = require("child_process");
17
+ const url_1 = require("url");
18
+ // ── Arg parsing ───────────────────────────────────────────────────────────────
19
+ function showUsage() {
20
+ console.error("Usage:\n" +
21
+ " tsx debugSignalR.ts https://dev.azure.com/ORG/PROJECT <buildId>\n" +
22
+ " tsx debugSignalR.ts https://dev.azure.com/ORG/PROJECT/_build/results?buildId=<id>\n" +
23
+ " tsx debugSignalR.ts https://dev.azure.com/ORG PROJECT <buildId>\n" +
24
+ " tsx debugSignalR.ts ORG/PROJECT <buildId>");
25
+ process.exit(1);
26
+ }
27
+ const positional = process.argv.slice(2).filter(a => !a.startsWith("--"));
28
+ let ORG, PROJECT, BUILD_ID;
29
+ const first = positional[0] ?? "";
30
+ if (first.startsWith("http")) {
31
+ const u = new url_1.URL(first);
32
+ const parts = u.pathname.split("/").filter(Boolean);
33
+ ORG = parts[0] ?? "";
34
+ const bidParam = u.searchParams.get("buildId");
35
+ if (parts.length >= 2 && !parts[1].startsWith("_")) {
36
+ // https://dev.azure.com/ORG/PROJECT[/...][?buildId=N]
37
+ PROJECT = parts[1];
38
+ BUILD_ID = bidParam ? Number(bidParam) : Number(positional[1] ?? "0");
39
+ }
40
+ else {
41
+ // https://dev.azure.com/ORG PROJECT buildId
42
+ PROJECT = positional[1] ?? "";
43
+ BUILD_ID = Number(positional[2] ?? "0");
44
+ }
45
+ }
46
+ else if (first.includes("/")) {
47
+ // ORG/PROJECT buildId
48
+ const slash = first.indexOf("/");
49
+ ORG = first.slice(0, slash);
50
+ PROJECT = first.slice(slash + 1);
51
+ BUILD_ID = Number(positional[1] ?? "0");
52
+ }
53
+ else {
54
+ showUsage();
55
+ }
56
+ if (!ORG || !PROJECT || !BUILD_ID)
57
+ showUsage();
58
+ const ADO_RESOURCE = "499b84ac-1321-427f-aa17-267ca6975798";
59
+ function getToken() {
60
+ const raw = (0, child_process_1.execSync)(`az account get-access-token --resource ${ADO_RESOURCE} --output json`, { encoding: "utf8" });
61
+ return JSON.parse(raw).accessToken;
62
+ }
63
+ function getProjectId(org, project) {
64
+ const raw = (0, child_process_1.execSync)(`az devops project show --project "${project}" --organization https://dev.azure.com/${org} --query id -o tsv`, { encoding: "utf8" });
65
+ return raw.trim();
66
+ }
67
+ function httpGet(url, token) {
68
+ return new Promise((resolve, reject) => {
69
+ const u = new url_1.URL(url);
70
+ const req = https_1.default.get({ hostname: u.hostname, path: u.pathname + u.search,
71
+ headers: { Authorization: `Bearer ${token}`, Accept: "application/json" } }, res => {
72
+ // Follow redirects
73
+ if ((res.statusCode ?? 0) >= 300 && (res.statusCode ?? 0) < 400) {
74
+ const loc = res.headers["location"];
75
+ res.resume();
76
+ return resolve(httpGet(loc.startsWith("http") ? loc : `https://${u.hostname}${loc}`, token));
77
+ }
78
+ let data = "";
79
+ res.on("data", (c) => (data += c));
80
+ res.on("end", () => resolve(data));
81
+ });
82
+ req.on("error", reject);
83
+ });
84
+ }
85
+ async function main() {
86
+ const token = getToken();
87
+ const projectId = getProjectId(ORG, PROJECT);
88
+ console.log("projectId:", projectId);
89
+ const orgEncoded = encodeURIComponent(ORG);
90
+ // 1. Get instanceId (org-level, used for negotiate)
91
+ const connData = JSON.parse(await httpGet(`https://dev.azure.com/${orgEncoded}/_apis/connectionData?connectOptions=0&lastChangeId=-1&lastChangeId64=-1`, token));
92
+ console.log("instanceId (for negotiate):", connData.instanceId);
93
+ // 2. Negotiate with instanceId → returns contextToken
94
+ const CONNECTION_DATA = encodeURIComponent(JSON.stringify([
95
+ { name: "builddetailhub" },
96
+ { name: "taskagentpoolhub" },
97
+ ]));
98
+ const negotiateUrl = `https://dev.azure.com/_signalr/${orgEncoded}/_apis/${connData.instanceId}/signalr/negotiate` +
99
+ `?transport=webSockets&clientProtocol=1.5&connectionData=${CONNECTION_DATA}&_=${Date.now()}`;
100
+ console.log("negotiate URL:", negotiateUrl);
101
+ const negotiated = JSON.parse(await httpGet(negotiateUrl, token));
102
+ console.log("negotiate response:", JSON.stringify(negotiated, null, 2));
103
+ // Extract contextToken from Url field
104
+ const contextToken = negotiated.Url?.match(/\/_apis\/([0-9a-f-]{36})\//i)?.[1];
105
+ console.log("contextToken:", contextToken);
106
+ // 3. Connect WebSocket — projectId in path, contextToken (from negotiate) as query param
107
+ const wsUrl = `wss://dev.azure.com/_signalr/${orgEncoded}/_apis/${projectId}/signalr/connect` +
108
+ `?transport=webSockets&clientProtocol=1.5` +
109
+ `&connectionToken=${encodeURIComponent(negotiated.ConnectionToken)}` +
110
+ `&connectionData=${CONNECTION_DATA}` +
111
+ (contextToken ? `&contextToken=${encodeURIComponent(contextToken)}` : "") +
112
+ `&tid=0`;
113
+ console.log("WebSocket URL:", wsUrl);
114
+ const ws = new ws_1.default(wsUrl, {
115
+ headers: {
116
+ Authorization: `Bearer ${token}`,
117
+ "Sec-WebSocket-Protocol": `Bearer, ${token}`,
118
+ },
119
+ });
120
+ ws.on("open", async () => {
121
+ console.log("WebSocket open");
122
+ // Start
123
+ try {
124
+ const startResp = await httpGet(`https://dev.azure.com/_signalr/${orgEncoded}/_apis/${connData.instanceId}/signalr/start` +
125
+ `?transport=webSockets&clientProtocol=1.5` +
126
+ `&connectionToken=${encodeURIComponent(negotiated.ConnectionToken)}` +
127
+ `&connectionData=${CONNECTION_DATA}&_=${Date.now()}`, token);
128
+ console.log("start response:", startResp);
129
+ }
130
+ catch (e) {
131
+ console.log("start error:", e.message);
132
+ }
133
+ // WatchBuild(projectId: Guid, buildId: Int32) — confirmed signature
134
+ const msg = JSON.stringify({ H: "builddetailhub", M: "WatchBuild", A: [projectId, BUILD_ID], I: "1" });
135
+ console.log("→ sending:", msg);
136
+ ws.send(msg);
137
+ });
138
+ ws.on("message", (raw) => {
139
+ const text = raw.toString();
140
+ if (text === "{}") {
141
+ process.stdout.write(".");
142
+ return;
143
+ }
144
+ console.log("\n← received:", text);
145
+ });
146
+ ws.on("close", () => { console.log("\nWebSocket closed"); process.exit(0); });
147
+ ws.on("error", (e) => { console.log("WebSocket error:", e.message); });
148
+ // Run for 2 minutes
149
+ setTimeout(() => { console.log("\nTimeout — closing"); ws.close(); }, 120_000);
150
+ }
151
+ main().catch(e => { console.error("Fatal:", e.message); process.exit(1); });
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /**
4
+ * debugWarnings.ts — report warning counts per stage/job/task for a build
5
+ *
6
+ * Usage:
7
+ * npx tsx debugWarnings.ts <org>/<project> <buildId> [--logs]
8
+ *
9
+ * Without --logs: prints a tree of stages/jobs/tasks with warning counts.
10
+ * With --logs: also fetches log content and prints each ##[warning] line.
11
+ */
12
+ var __importDefault = (this && this.__importDefault) || function (mod) {
13
+ return (mod && mod.__esModule) ? mod : { "default": mod };
14
+ };
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ const https_1 = __importDefault(require("https"));
17
+ const child_process_1 = require("child_process");
18
+ const ADO_RESOURCE = "499b84ac-1321-427f-aa17-267ca6975798";
19
+ const API_VER = "api-version=7.1";
20
+ const rawArgs = process.argv.slice(2);
21
+ const [orgProject, buildId] = rawArgs.filter(a => !a.startsWith("--"));
22
+ const showLogs = rawArgs.includes("--logs");
23
+ if (!orgProject || !buildId) {
24
+ console.error("Usage: npx tsx debugWarnings.ts <org>/<project> <buildId> [--logs]");
25
+ process.exit(1);
26
+ }
27
+ const [org, ...rest] = orgProject.split("/");
28
+ const project = rest.join("/");
29
+ const enc = encodeURIComponent;
30
+ const ADO_BASE = `https://dev.azure.com/${enc(org)}/${enc(project)}/_apis/build/builds/${buildId}`;
31
+ function getToken() {
32
+ const raw = (0, child_process_1.execSync)(`az account get-access-token --resource ${ADO_RESOURCE} --output json`, { encoding: "utf8", env: { ...process.env, AZURE_CONFIG_DIR: process.env["AZURE_CONFIG_DIR"] } });
33
+ return JSON.parse(raw).accessToken;
34
+ }
35
+ function httpGet(url, token) {
36
+ return new Promise((resolve, reject) => {
37
+ const u = new URL(url);
38
+ https_1.default.get({ hostname: u.hostname, path: u.pathname + u.search,
39
+ headers: { Authorization: `Bearer ${token}`, Accept: "application/json" } }, res => {
40
+ let data = "";
41
+ res.on("data", (c) => (data += c));
42
+ res.on("end", () => {
43
+ if ((res.statusCode ?? 0) >= 400)
44
+ return reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 300)}`));
45
+ resolve(JSON.parse(data));
46
+ });
47
+ }).on("error", reject);
48
+ });
49
+ }
50
+ function httpGetText(url, token) {
51
+ return new Promise((resolve, reject) => {
52
+ const u = new URL(url);
53
+ https_1.default.get({ hostname: u.hostname, path: u.pathname + u.search,
54
+ headers: { Authorization: `Bearer ${token}`, Accept: "text/plain" } }, res => {
55
+ let data = "";
56
+ res.on("data", (c) => (data += c));
57
+ res.on("end", () => {
58
+ if ((res.statusCode ?? 0) >= 400)
59
+ return reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 300)}`));
60
+ resolve(data);
61
+ });
62
+ }).on("error", reject);
63
+ });
64
+ }
65
+ function badge(warnings, errors) {
66
+ const parts = [];
67
+ if (warnings > 0)
68
+ parts.push(`⚠ ${warnings}w`);
69
+ if (errors > 0)
70
+ parts.push(`✗ ${errors}e`);
71
+ return parts.length ? ` [${parts.join(" ")}]` : "";
72
+ }
73
+ async function main() {
74
+ console.log(`\nOrg: ${org} Project: ${project} BuildId: ${buildId}`);
75
+ console.log("Fetching token…");
76
+ const token = getToken();
77
+ console.log("Token OK\n");
78
+ const timeline = await httpGet(`${ADO_BASE}/timeline?${API_VER}`, token);
79
+ const records = timeline.records ?? [];
80
+ // Build parent → children index
81
+ const children = new Map();
82
+ for (const r of records) {
83
+ const p = r.parentId ?? null;
84
+ if (!children.has(p))
85
+ children.set(p, []);
86
+ children.get(p).push(r);
87
+ }
88
+ // Stages = top-level records of type Stage
89
+ const stages = (children.get(null) ?? [])
90
+ .filter(r => r.type === "Stage")
91
+ .sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
92
+ // Totals from leaf records only (Tasks have no children) to avoid double-counting.
93
+ // Stage/Phase/Job warningCount is often 0 even when children have warnings.
94
+ const hasChildren = new Set(records.map(r => r.parentId).filter(Boolean));
95
+ let totalWarnings = 0;
96
+ let totalErrors = 0;
97
+ for (const r of records) {
98
+ if (!hasChildren.has(r.id)) {
99
+ totalWarnings += r.warningCount ?? 0;
100
+ totalErrors += r.errorCount ?? 0;
101
+ }
102
+ }
103
+ function sumDescendants(nodeId) {
104
+ const kids = children.get(nodeId) ?? [];
105
+ if (kids.length === 0)
106
+ return { warnings: 0, errors: 0 };
107
+ let w = 0, e = 0;
108
+ for (const kid of kids) {
109
+ if (!hasChildren.has(kid.id)) {
110
+ w += kid.warningCount ?? 0;
111
+ e += kid.errorCount ?? 0;
112
+ }
113
+ else {
114
+ const s = sumDescendants(kid.id);
115
+ w += s.warnings;
116
+ e += s.errors;
117
+ }
118
+ }
119
+ return { warnings: w, errors: e };
120
+ }
121
+ // Collect tasks with logs that have warnings (for --logs mode)
122
+ const warningTasks = [];
123
+ function printTree(nodes, indent) {
124
+ const sorted = [...nodes].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
125
+ for (const node of sorted) {
126
+ const w = node.warningCount ?? 0;
127
+ const e = node.errorCount ?? 0;
128
+ const b = badge(w, e);
129
+ console.log(`${indent}${node.type.padEnd(6)} ${node.name}${b}`);
130
+ if (showLogs && w > 0 && node.log?.url) {
131
+ warningTasks.push({ name: node.name, logUrl: node.log.url });
132
+ }
133
+ const kids = children.get(node.id) ?? [];
134
+ if (kids.length)
135
+ printTree(kids, indent + " ");
136
+ }
137
+ }
138
+ console.log("── Warning/Error tree ────────────────────────────────────────────");
139
+ for (const stage of stages) {
140
+ const { warnings: sw, errors: se } = sumDescendants(stage.id);
141
+ console.log(`\nSTAGE ${stage.name} (state=${stage.state} result=${stage.result ?? "—"})${badge(sw, se)}`);
142
+ const kids = children.get(stage.id) ?? [];
143
+ printTree(kids, " ");
144
+ }
145
+ console.log(`\n── Totals ────────────────────────────────────────────────────────`);
146
+ console.log(` Warnings: ${totalWarnings}`);
147
+ console.log(` Errors : ${totalErrors}`);
148
+ if (!showLogs || warningTasks.length === 0)
149
+ return;
150
+ console.log(`\n── Warning log lines (${warningTasks.length} tasks) ──────────────────────────`);
151
+ for (const task of warningTasks) {
152
+ console.log(`\n [${task.name}]`);
153
+ try {
154
+ const text = await httpGetText(`${task.logUrl}?${API_VER}`, token);
155
+ const lines = text.split("\n").filter(l => /##\[warning\]/i.test(l));
156
+ if (lines.length === 0) {
157
+ console.log(" (no ##[warning] lines found in log)");
158
+ }
159
+ else {
160
+ for (const line of lines)
161
+ console.log(` ${line.trim()}`);
162
+ }
163
+ }
164
+ catch (e) {
165
+ console.log(` Error fetching log: ${e.message}`);
166
+ }
167
+ }
168
+ }
169
+ main().catch(e => { console.error(e); process.exit(1); });
package/dist/lib/api.js CHANGED
@@ -21,6 +21,8 @@ exports.buildBase = buildBase;
21
21
  exports.fetchBuild = fetchBuild;
22
22
  exports.fetchTimeline = fetchTimeline;
23
23
  exports.fetchLogLines = fetchLogLines;
24
+ exports.fetchOrgs = fetchOrgs;
25
+ exports.fetchProjects = fetchProjects;
24
26
  const https_1 = __importDefault(require("https"));
25
27
  const child_process_1 = require("child_process");
26
28
  const url_1 = require("url");
@@ -254,3 +256,13 @@ async function fetchTimeline(org, project, buildId, token) {
254
256
  async function fetchLogLines(org, project, buildId, logId, startLine, token) {
255
257
  return httpGet(`${buildBase(org, project, buildId)}/logs/${logId}?startLine=${startLine}&${exports.API_VER}`, token).catch(() => null);
256
258
  }
259
+ async function fetchOrgs(token) {
260
+ const conn = await httpGet(`https://app.vssps.visualstudio.com/_apis/connectionData`, token);
261
+ const memberId = conn.authenticatedUser.id;
262
+ const data = await httpGet(`https://app.vssps.visualstudio.com/_apis/accounts?memberId=${memberId}&api-version=7.1-preview.1`, token);
263
+ return (data.value ?? []).sort((a, b) => a.accountName.localeCompare(b.accountName));
264
+ }
265
+ async function fetchProjects(org, token) {
266
+ const data = await httpGet(`https://dev.azure.com/${(0, exports.enc)(org)}/_apis/projects?$top=200&stateFilter=wellFormed&${exports.API_VER}`, token);
267
+ return (data.value ?? []).sort((a, b) => a.name.localeCompare(b.name));
268
+ }
@@ -191,15 +191,16 @@ function formatEnvItem(item) {
191
191
  }
192
192
  let stats = "";
193
193
  if (item.ok > 0)
194
- stats += ` {green-fg}${item.ok}✓{/}`;
194
+ stats += `{green-fg}${item.ok}✓{/} `;
195
195
  if (item.fail > 0)
196
- stats += ` {red-fg}${item.fail}✗{/}`;
196
+ stats += `{red-fg}${item.fail}✗{/} `;
197
197
  const other = item.total - item.ok - item.fail;
198
198
  if (other > 0)
199
- stats += ` {gray-fg}${other}…{/}`;
199
+ stats += `{gray-fg}${other}…{/}`;
200
200
  if (!stats && item.total === 0)
201
- stats = ` {gray-fg}empty{/}`;
202
- return `{bold}${left}{/}${stats}`;
201
+ stats = `{gray-fg}empty{/}`;
202
+ const pipeBlank = " " + " ".repeat(36);
203
+ return `{bold}${left}{/}${pipeBlank} ${stats.trim()}`;
203
204
  }
204
205
  const pfx = indent + (item.isLast ? "└─ " : "├─ ");
205
206
  const label = padEnd(item.label, Math.max(1, exports.LEFT_COL - 1 - pfx.length));
@@ -47,9 +47,14 @@ class EnvironmentsScreen {
47
47
  rows = [];
48
48
  flatItems = [];
49
49
  collapsed = new Set();
50
+ helpVisible = false;
50
51
  get footerText() {
51
- return (" {cyan-fg}↑↓{/} Navigate {cyan-fg}Enter{/} Expand/Stages {cyan-fg}←{/} Collapse " +
52
- "{cyan-fg}p{/} Pipelines {cyan-fg}m{/} Mapping {cyan-fg}r{/} Refresh {cyan-fg}c{/} Clear {cyan-fg}q{/} Quit");
52
+ if (this.helpVisible) {
53
+ return (" {cyan-fg}↑↓{/} Navigate {cyan-fg}Enter{/} Run/Expand {cyan-fg}s{/} Stages {cyan-fg}←→{/} Collapse/Expand" +
54
+ " {cyan-fg}b{/} Browser {cyan-fg}p{/} Pipelines {cyan-fg}m{/} Mapping" +
55
+ " {cyan-fg}r{/} Refresh {cyan-fg}c{/} Clear cache {cyan-fg}h{/} Close help {cyan-fg}q{/} Quit");
56
+ }
57
+ return " {cyan-fg}↑↓{/} Navigate {cyan-fg}Enter{/} Run/Expand {cyan-fg}s{/} Stages {cyan-fg}p{/} Pipelines {cyan-fg}←→{/} Collapse/Expand {cyan-fg}b{/} Browser {cyan-fg}h{/} Help {cyan-fg}q{/} Quit";
53
58
  }
54
59
  constructor(screen, ctx) {
55
60
  this.screen = screen;
@@ -98,24 +103,25 @@ class EnvironmentsScreen {
98
103
  const idx = this.widget.selected ?? 0;
99
104
  return this.flatItems[idx];
100
105
  }
101
- async openStagesForRow(row) {
102
- if (!row.mapping) {
103
- const bid = row.deploy?.owner?.id ? Number(row.deploy.owner.id) : 0;
104
- if (!bid)
105
- return;
106
- const url = `https://dev.azure.com/${this.ctx.org}/${this.ctx.project}/_build/results?buildId=${bid}`;
107
- try {
108
- (0, child_process_1.spawn)("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref();
109
- }
110
- catch { }
106
+ navigateRunForRow(row) {
107
+ const bid = row.deploy?.owner?.id ? Number(row.deploy.owner.id) : 0;
108
+ if (bid)
109
+ this.ctx.navigate({ view: "pipelineRun", buildId: String(bid) });
110
+ else
111
+ this.ctx.setStatus("No deployment found for this environment");
112
+ }
113
+ async navigateStagesForRow(row) {
114
+ const pipelineId = row.mapping?.pipelineId ?? row.deploy?.definition?.id;
115
+ if (!pipelineId) {
116
+ this.ctx.setStatus("No pipeline found for this environment");
111
117
  return;
112
118
  }
113
- let pip = this.ctx.state.pipelines.find(p => p.id === row.mapping.pipelineId);
119
+ let pip = this.ctx.state.pipelines.find(p => p.id === pipelineId);
114
120
  if (!pip) {
115
121
  this.ctx.setStatus("Loading pipeline definitions…", 0);
116
122
  try {
117
123
  await this.ctx.loadPipelineDefinitions();
118
- pip = this.ctx.state.pipelines.find(p => p.id === row.mapping.pipelineId);
124
+ pip = this.ctx.state.pipelines.find(p => p.id === pipelineId);
119
125
  }
120
126
  catch (e) {
121
127
  this.ctx.setStatus(`Error: ${e.message.slice(0, 80)}`, 5000);
@@ -125,7 +131,19 @@ class EnvironmentsScreen {
125
131
  if (pip)
126
132
  this.ctx.navigate({ view: "stages", pipeline: pip });
127
133
  else
128
- this.ctx.setStatus(`Pipeline ${row.mapping.pipelineId} not found`);
134
+ this.ctx.setStatus(`Pipeline ${pipelineId} not found`);
135
+ }
136
+ openBrowserForRow(row) {
137
+ const bid = row.deploy?.owner?.id ? Number(row.deploy.owner.id) : 0;
138
+ if (!bid) {
139
+ this.ctx.setStatus("No deployment found for this environment");
140
+ return;
141
+ }
142
+ const url = `https://dev.azure.com/${this.ctx.org}/${this.ctx.project}/_build/results?buildId=${bid}`;
143
+ try {
144
+ (0, child_process_1.spawn)("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref();
145
+ }
146
+ catch { }
129
147
  }
130
148
  async loadData() {
131
149
  this.ctx.setStatus("Loading environments…", 0);
@@ -181,6 +199,22 @@ class EnvironmentsScreen {
181
199
  this.loadData();
182
200
  });
183
201
  this.widget.key("c", () => { (0, cache_js_1.clearAllCache)(); this.ctx.setStatus("All caches cleared"); });
202
+ this.widget.key("h", () => {
203
+ this.helpVisible = !this.helpVisible;
204
+ this.ctx.setStatus("", 0);
205
+ });
206
+ this.widget.key("b", () => {
207
+ const item = this.selected();
208
+ if (!item || item.kind !== "leaf")
209
+ return;
210
+ this.openBrowserForRow(item.row);
211
+ });
212
+ this.widget.key("s", () => {
213
+ const item = this.selected();
214
+ if (!item || item.kind !== "leaf")
215
+ return;
216
+ this.navigateStagesForRow(item.row);
217
+ });
184
218
  this.widget.key("enter", () => {
185
219
  const item = this.selected();
186
220
  if (!item)
@@ -193,7 +227,7 @@ class EnvironmentsScreen {
193
227
  this.refresh();
194
228
  }
195
229
  else {
196
- this.openStagesForRow(item.row);
230
+ this.navigateRunForRow(item.row);
197
231
  }
198
232
  });
199
233
  this.widget.key("left", () => {
@@ -215,6 +249,13 @@ class EnvironmentsScreen {
215
249
  }
216
250
  }
217
251
  });
252
+ this.widget.key("right", () => {
253
+ const item = this.selected();
254
+ if (!item || item.kind !== "group" || item.isExpanded)
255
+ return;
256
+ this.collapsed.delete(item.key);
257
+ this.refresh();
258
+ });
218
259
  }
219
260
  }
220
261
  exports.EnvironmentsScreen = EnvironmentsScreen;
@@ -0,0 +1,102 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.OrgsScreen = void 0;
37
+ const blessed = __importStar(require("blessed"));
38
+ const api_js_1 = require("../lib/api.js");
39
+ class OrgsScreen {
40
+ screen;
41
+ ctx;
42
+ widget;
43
+ orgs = [];
44
+ get footerText() {
45
+ return " {cyan-fg}↑↓{/} Navigate {cyan-fg}Enter{/} Select org {cyan-fg}r{/} Refresh {cyan-fg}q{/} Quit";
46
+ }
47
+ constructor(screen, ctx) {
48
+ this.screen = screen;
49
+ this.ctx = ctx;
50
+ this.widget = blessed.list({
51
+ parent: screen, top: 1, left: 0, width: "100%", height: "100%-3",
52
+ border: { type: "line" }, label: " Azure DevOps Organizations ",
53
+ tags: true, keys: true, vi: true, scrollable: true,
54
+ scrollbar: { ch: "│", style: { fg: "blue" } },
55
+ style: {
56
+ border: { fg: "cyan" }, selected: { bg: "blue", fg: "white" },
57
+ focus: { border: { fg: "white" } },
58
+ },
59
+ items: [], hidden: true,
60
+ });
61
+ this.registerKeys();
62
+ }
63
+ show() {
64
+ this.widget.show();
65
+ this.widget.focus();
66
+ if (this.orgs.length === 0)
67
+ this.load();
68
+ else
69
+ this.render();
70
+ }
71
+ hide() { this.widget.hide(); }
72
+ async load() {
73
+ this.ctx.setStatus("Loading organizations…", 0);
74
+ try {
75
+ const token = await this.ctx.getToken();
76
+ this.orgs = await (0, api_js_1.fetchOrgs)(token);
77
+ this.ctx.setStatus("", 0);
78
+ this.render();
79
+ }
80
+ catch (e) {
81
+ this.ctx.setStatus(`Error: ${e.message.slice(0, 80)}`, 10_000);
82
+ }
83
+ }
84
+ render() {
85
+ this.widget.setItems(this.orgs.map(o => o.accountName));
86
+ this.screen.render();
87
+ }
88
+ registerKeys() {
89
+ this.widget.key("enter", () => {
90
+ const idx = this.widget.selected ?? 0;
91
+ const org = this.orgs[idx];
92
+ if (!org)
93
+ return;
94
+ this.ctx.navigate({ view: "projects", org: org.accountName });
95
+ });
96
+ this.widget.key("r", () => {
97
+ this.orgs = [];
98
+ this.load();
99
+ });
100
+ }
101
+ }
102
+ exports.OrgsScreen = OrgsScreen;
@@ -0,0 +1,112 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.ProjectsScreen = void 0;
37
+ const blessed = __importStar(require("blessed"));
38
+ const api_js_1 = require("../lib/api.js");
39
+ class ProjectsScreen {
40
+ screen;
41
+ ctx;
42
+ widget;
43
+ org = "";
44
+ projects = [];
45
+ get footerText() {
46
+ return " {cyan-fg}↑↓{/} Navigate {cyan-fg}Enter{/} Select project {cyan-fg}Esc{/} Back {cyan-fg}r{/} Refresh {cyan-fg}q{/} Quit";
47
+ }
48
+ constructor(screen, ctx) {
49
+ this.screen = screen;
50
+ this.ctx = ctx;
51
+ this.widget = blessed.list({
52
+ parent: screen, top: 1, left: 0, width: "100%", height: "100%-3",
53
+ border: { type: "line" }, label: " Projects ",
54
+ tags: true, keys: true, vi: true, scrollable: true,
55
+ scrollbar: { ch: "│", style: { fg: "blue" } },
56
+ style: {
57
+ border: { fg: "cyan" }, selected: { bg: "blue", fg: "white" },
58
+ focus: { border: { fg: "white" } },
59
+ },
60
+ items: [], hidden: true,
61
+ });
62
+ this.registerKeys();
63
+ }
64
+ show(org) {
65
+ if (org !== this.org) {
66
+ this.org = org;
67
+ this.projects = [];
68
+ }
69
+ this.widget.setLabel(` Projects — ${org} `);
70
+ this.widget.show();
71
+ this.widget.focus();
72
+ if (this.projects.length === 0)
73
+ this.load();
74
+ else
75
+ this.render();
76
+ }
77
+ hide() { this.widget.hide(); }
78
+ async load() {
79
+ this.ctx.setStatus(`Loading projects for ${this.org}…`, 0);
80
+ try {
81
+ const token = await this.ctx.getToken();
82
+ this.projects = await (0, api_js_1.fetchProjects)(this.org, token);
83
+ this.ctx.setStatus("", 0);
84
+ this.render();
85
+ }
86
+ catch (e) {
87
+ this.ctx.setStatus(`Error: ${e.message.slice(0, 80)}`, 10_000);
88
+ }
89
+ }
90
+ render() {
91
+ this.widget.setItems(this.projects.map(p => p.name));
92
+ this.screen.render();
93
+ }
94
+ registerKeys() {
95
+ this.widget.key("enter", () => {
96
+ const idx = this.widget.selected ?? 0;
97
+ const project = this.projects[idx];
98
+ if (!project)
99
+ return;
100
+ this.ctx.setOrgProject(this.org, project.name);
101
+ this.ctx.navigate({ view: "pipelines" });
102
+ });
103
+ this.widget.key(["escape", "backspace"], () => {
104
+ this.ctx.navigate({ view: "orgs" });
105
+ });
106
+ this.widget.key("r", () => {
107
+ this.projects = [];
108
+ this.load();
109
+ });
110
+ }
111
+ }
112
+ exports.ProjectsScreen = ProjectsScreen;
package/dist/tui.js CHANGED
@@ -48,18 +48,20 @@ const StagesScreen_js_1 = require("./screens/StagesScreen.js");
48
48
  const PipelineRunsScreen_js_1 = require("./screens/PipelineRunsScreen.js");
49
49
  const PipelineRunScreen_js_1 = require("./screens/PipelineRunScreen.js");
50
50
  const MappingScreen_js_1 = require("./screens/MappingScreen.js");
51
+ const OrgsScreen_js_1 = require("./screens/OrgsScreen.js");
52
+ const ProjectsScreen_js_1 = require("./screens/ProjectsScreen.js");
51
53
  // ── CLI args ──────────────────────────────────────────────────────────────────
52
54
  function showHelp() {
53
55
  console.log(`
54
56
  Azure Pipelines TUI
55
57
 
56
58
  Usage:
57
- npx tsx src/tui.ts ORG/PROJECT Pipelines Overview (default)
58
- npx tsx src/tui.ts ORG/PROJECT --envs Environments Overview
59
- npx tsx src/tui.ts ORG/PROJECT --stages <id> Stages Dashboard
60
- npx tsx src/tui.ts ORG/PROJECT --runs <id> Pipeline Runs List
61
- npx tsx src/tui.ts <build-url> Pipeline Run (single build)
62
- npx tsx src/tui.ts ORG/PROJECT <buildId> Pipeline Run (single build)
59
+ npx azure-pipelines-tui ORG/PROJECT Pipelines Overview (default)
60
+ npx azure-pipelines-tui ORG/PROJECT --envs Environments Overview
61
+ npx azure-pipelines-tui ORG/PROJECT --stages <id> Stages Dashboard
62
+ npx azure-pipelines-tui ORG/PROJECT --runs <id> Pipeline Runs List
63
+ npx azure-pipelines-tui <build-url> Pipeline Run (single build)
64
+ npx azure-pipelines-tui ORG/PROJECT <buildId> Pipeline Run (single build)
63
65
 
64
66
  Options:
65
67
  --config <file> Config file (default: environments-config.json)
@@ -140,6 +142,8 @@ else if (RUNS_ARG)
140
142
  INITIAL_VIEW = "runs";
141
143
  else if (ENVS_FLAG)
142
144
  INITIAL_VIEW = "environments";
145
+ else if (!ORG)
146
+ INITIAL_VIEW = "orgs";
143
147
  // ── Main ──────────────────────────────────────────────────────────────────────
144
148
  async function main() {
145
149
  const config = (0, api_js_1.loadConfig)(CONFIG_FILE);
@@ -147,13 +151,15 @@ async function main() {
147
151
  ORG = config.org ?? "";
148
152
  if (!PROJECT)
149
153
  PROJECT = config.project ?? "";
150
- if (!ORG || !PROJECT) {
154
+ if ((!ORG || !PROJECT) && INITIAL_VIEW !== "orgs") {
151
155
  console.error("Error: org/project required. Pass as 'org/project' argument or set in environments-config.json.\n" +
152
156
  "Run with --help for usage.");
153
157
  process.exit(1);
154
158
  }
155
- config.org = ORG;
156
- config.project = PROJECT;
159
+ if (ORG)
160
+ config.org = ORG;
161
+ if (PROJECT)
162
+ config.project = PROJECT;
157
163
  // ── Shared state ──────────────────────────────────────────────────────────
158
164
  const state = { pipelines: [] };
159
165
  // ── Screen + widgets ──────────────────────────────────────────────────────
@@ -169,7 +175,7 @@ async function main() {
169
175
  const headerBox = blessed.box({
170
176
  parent: screen, top: 0, left: 0, width: "100%", height: 1, tags: true,
171
177
  style: { bg: "blue", fg: "white", bold: true },
172
- content: ` {bold}Azure Pipelines TUI{/bold} ${ORG} / ${PROJECT}`,
178
+ content: ` {bold}Azure Pipelines TUI{/bold}`,
173
179
  });
174
180
  const footerBox = blessed.box({
175
181
  parent: screen, bottom: 0, left: 0, width: "100%", height: 1, tags: true,
@@ -192,7 +198,13 @@ async function main() {
192
198
  let currentDest = { view: "pipelines" };
193
199
  function updateHeader() {
194
200
  let content;
195
- if (currentView === "pipelineRun") {
201
+ if (currentView === "orgs") {
202
+ content = ` {bold}Azure Pipelines TUI{/bold} Select organization`;
203
+ }
204
+ else if (currentView === "projects") {
205
+ content = ` {bold}Azure Pipelines TUI{/bold} ${ORG} — Select project`;
206
+ }
207
+ else if (currentView === "pipelineRun") {
196
208
  const run = screens.pipelineRun;
197
209
  const build = run.getBuild();
198
210
  const id = run.getBuildId();
@@ -242,6 +254,12 @@ async function main() {
242
254
  currentView = dest.view;
243
255
  hideAll();
244
256
  switch (dest.view) {
257
+ case "orgs":
258
+ screens.orgs.show();
259
+ break;
260
+ case "projects":
261
+ screens.projects.show(dest.org);
262
+ break;
245
263
  case "pipelines":
246
264
  screens.pipelines.show();
247
265
  break;
@@ -269,14 +287,21 @@ async function main() {
269
287
  }
270
288
  // ── App context ────────────────────────────────────────────────────────────
271
289
  const ctx = {
272
- org: ORG,
273
- project: PROJECT,
290
+ get org() { return ORG; },
291
+ get project() { return PROJECT; },
274
292
  config,
275
293
  state,
276
294
  getToken: () => (0, api_js_1.getToken)(config.azConfigDir),
277
295
  navigate,
278
296
  goBack,
279
297
  setStatus,
298
+ setOrgProject(org, project) {
299
+ ORG = org;
300
+ PROJECT = project;
301
+ config.org = org;
302
+ config.project = project;
303
+ state.pipelines = [];
304
+ },
280
305
  loadPipelineDefinitions: async () => {
281
306
  if (state.pipelines.length > 0)
282
307
  return;
@@ -294,6 +319,8 @@ async function main() {
294
319
  // ── Create screens ─────────────────────────────────────────────────────────
295
320
  const envScreen = new EnvironmentsScreen_js_1.EnvironmentsScreen(screen, ctx);
296
321
  const screens = {
322
+ orgs: new OrgsScreen_js_1.OrgsScreen(screen, ctx),
323
+ projects: new ProjectsScreen_js_1.ProjectsScreen(screen, ctx),
297
324
  pipelines: new PipelinesScreen_js_1.PipelinesScreen(screen, ctx),
298
325
  environments: envScreen,
299
326
  stages: new StagesScreen_js_1.StagesScreen(screen, ctx),
@@ -314,7 +341,10 @@ async function main() {
314
341
  }
315
342
  });
316
343
  // ── Initial navigation ────────────────────────────────────────────────────
317
- if (INITIAL_VIEW === "pipelineRun" && INITIAL_BUILD_ID) {
344
+ if (INITIAL_VIEW === "orgs") {
345
+ navigate({ view: "orgs" });
346
+ }
347
+ else if (INITIAL_VIEW === "pipelineRun" && INITIAL_BUILD_ID) {
318
348
  navigate({ view: "pipelineRun", buildId: INITIAL_BUILD_ID });
319
349
  }
320
350
  else if (INITIAL_VIEW === "stages" && STAGES_ARG) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "azure-pipelines-tui",
3
- "version": "0.5.1",
4
- "description": "Azure Pipelines TUI log viewer",
3
+ "version": "0.5.3",
4
+ "description": "Azure Pipelines TUI",
5
5
  "bin": {
6
6
  "azure-pipelines-tui": "./dist/tui.js"
7
7
  },