azure-pipelines-tui 0.5.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/README.md +114 -0
- package/dist/cache.js +54 -0
- package/dist/debugRetry.js +150 -0
- package/dist/debugSignalR.js +151 -0
- package/dist/debugWarnings.js +169 -0
- package/dist/lib/api.js +256 -0
- package/dist/lib/format.js +580 -0
- package/dist/lib/types.js +3 -0
- package/dist/screens/EnvironmentsScreen.js +220 -0
- package/dist/screens/MappingScreen.js +165 -0
- package/dist/screens/PipelineRunScreen.js +408 -0
- package/dist/screens/PipelineRunsScreen.js +135 -0
- package/dist/screens/PipelinesScreen.js +184 -0
- package/dist/screens/StagesScreen.js +194 -0
- package/dist/screens/context.js +2 -0
- package/dist/signalr.js +117 -0
- package/dist/tui.js +358 -0
- package/package.json +31 -0
|
@@ -0,0 +1,184 @@
|
|
|
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.PipelinesScreen = void 0;
|
|
37
|
+
const child_process_1 = require("child_process");
|
|
38
|
+
const blessed = __importStar(require("blessed"));
|
|
39
|
+
const api_js_1 = require("../lib/api.js");
|
|
40
|
+
const format_js_1 = require("../lib/format.js");
|
|
41
|
+
const cache_js_1 = require("../cache.js");
|
|
42
|
+
class PipelinesScreen {
|
|
43
|
+
screen;
|
|
44
|
+
ctx;
|
|
45
|
+
widget;
|
|
46
|
+
flatItems = [];
|
|
47
|
+
collapsed = new Set();
|
|
48
|
+
get footerText() {
|
|
49
|
+
return (" {cyan-fg}↑↓{/} Navigate {cyan-fg}Enter{/} Stages {cyan-fg}v{/} Runs {cyan-fg}o{/} Open " +
|
|
50
|
+
"{cyan-fg}e{/} Envs {cyan-fg}b{/} Browser {cyan-fg}r{/} Refresh {cyan-fg}q{/} Quit");
|
|
51
|
+
}
|
|
52
|
+
constructor(screen, ctx) {
|
|
53
|
+
this.screen = screen;
|
|
54
|
+
this.ctx = ctx;
|
|
55
|
+
this.widget = blessed.list({
|
|
56
|
+
parent: screen, top: 1, left: 0, width: "100%", height: "100%-3",
|
|
57
|
+
border: { type: "line" }, label: " Pipeline Definitions ",
|
|
58
|
+
tags: true, keys: true, vi: true, scrollable: true,
|
|
59
|
+
scrollbar: { ch: "│", style: { fg: "blue" } },
|
|
60
|
+
style: {
|
|
61
|
+
border: { fg: "cyan" }, selected: { bg: "blue", fg: "white" },
|
|
62
|
+
focus: { border: { fg: "white" } },
|
|
63
|
+
},
|
|
64
|
+
items: [], hidden: true,
|
|
65
|
+
});
|
|
66
|
+
this.registerKeys();
|
|
67
|
+
}
|
|
68
|
+
show() {
|
|
69
|
+
this.widget.show();
|
|
70
|
+
this.widget.focus();
|
|
71
|
+
if (this.ctx.state.pipelines.length === 0) {
|
|
72
|
+
this.ctx.loadPipelineDefinitions().then(() => this.refresh());
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
this.refresh();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
hide() { this.widget.hide(); }
|
|
79
|
+
refresh(scrollTo) {
|
|
80
|
+
const tree = (0, format_js_1.buildPipeTree)(this.ctx.state.pipelines);
|
|
81
|
+
this.flatItems = [];
|
|
82
|
+
(0, format_js_1.flattenPipeTree)(tree, this.collapsed, 0, this.flatItems);
|
|
83
|
+
this.widget.setItems(this.flatItems.map(format_js_1.formatPipeItem));
|
|
84
|
+
if (scrollTo) {
|
|
85
|
+
const idx = this.flatItems.findIndex(i => i.kind === "pipeline" && i.pipeline.id === scrollTo.id);
|
|
86
|
+
if (idx >= 0) {
|
|
87
|
+
this.widget.select(idx);
|
|
88
|
+
const vis = this.widget.height - 2;
|
|
89
|
+
this.widget.scrollTo(Math.max(0, idx - Math.floor(vis / 2)));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
this.screen.render();
|
|
93
|
+
}
|
|
94
|
+
scrollTo(pipeline) { this.refresh(pipeline); }
|
|
95
|
+
selected() {
|
|
96
|
+
const idx = this.widget.selected ?? 0;
|
|
97
|
+
return this.flatItems[idx];
|
|
98
|
+
}
|
|
99
|
+
registerKeys() {
|
|
100
|
+
this.widget.key("e", () => this.ctx.navigate({ view: "environments" }));
|
|
101
|
+
this.widget.key("r", () => {
|
|
102
|
+
const { org, project } = this.ctx;
|
|
103
|
+
(0, cache_js_1.clearByPrefix)(`pipelines_${org}_${project}`);
|
|
104
|
+
this.ctx.state.pipelines = [];
|
|
105
|
+
this.ctx.setStatus("Refreshing…", 0);
|
|
106
|
+
this.ctx.loadPipelineDefinitions().then(() => this.refresh());
|
|
107
|
+
});
|
|
108
|
+
this.widget.key("b", () => {
|
|
109
|
+
const item = this.selected();
|
|
110
|
+
if (!item || item.kind !== "pipeline")
|
|
111
|
+
return;
|
|
112
|
+
const { org, project } = this.ctx;
|
|
113
|
+
const url = `https://dev.azure.com/${org}/${project}/_build?definitionId=${item.pipeline.id}&_a=summary`;
|
|
114
|
+
try {
|
|
115
|
+
(0, child_process_1.spawn)("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref();
|
|
116
|
+
}
|
|
117
|
+
catch { }
|
|
118
|
+
});
|
|
119
|
+
this.widget.key("enter", () => {
|
|
120
|
+
const item = this.selected();
|
|
121
|
+
if (!item)
|
|
122
|
+
return;
|
|
123
|
+
if (item.kind === "folder") {
|
|
124
|
+
if (this.collapsed.has(item.key))
|
|
125
|
+
this.collapsed.delete(item.key);
|
|
126
|
+
else
|
|
127
|
+
this.collapsed.add(item.key);
|
|
128
|
+
this.refresh();
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
this.ctx.navigate({ view: "stages", pipeline: item.pipeline });
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
this.widget.key("v", () => {
|
|
135
|
+
const item = this.selected();
|
|
136
|
+
if (!item || item.kind !== "pipeline")
|
|
137
|
+
return;
|
|
138
|
+
this.ctx.navigate({ view: "runs", pipeline: item.pipeline });
|
|
139
|
+
});
|
|
140
|
+
this.widget.key("o", async () => {
|
|
141
|
+
const item = this.selected();
|
|
142
|
+
if (!item || item.kind !== "pipeline")
|
|
143
|
+
return;
|
|
144
|
+
this.ctx.setStatus("Loading latest run…", 0);
|
|
145
|
+
try {
|
|
146
|
+
const token = await this.ctx.getToken();
|
|
147
|
+
const runs = await (0, api_js_1.fetchPipelineRuns)(this.ctx.org, this.ctx.project, item.pipeline.id, token, 1);
|
|
148
|
+
if (runs.length === 0) {
|
|
149
|
+
this.ctx.setStatus("No runs found for this pipeline");
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
this.ctx.navigate({ view: "pipelineRun", buildId: String(runs[0].id) });
|
|
153
|
+
}
|
|
154
|
+
catch (e) {
|
|
155
|
+
this.ctx.setStatus(`Error: ${e.message.slice(0, 80)}`, 5000);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
this.widget.key("right", () => {
|
|
159
|
+
const item = this.selected();
|
|
160
|
+
if (!item || item.kind !== "folder" || item.isExpanded)
|
|
161
|
+
return;
|
|
162
|
+
this.collapsed.delete(item.key);
|
|
163
|
+
this.refresh();
|
|
164
|
+
});
|
|
165
|
+
this.widget.key("left", () => {
|
|
166
|
+
const item = this.selected();
|
|
167
|
+
if (!item)
|
|
168
|
+
return;
|
|
169
|
+
if (item.kind === "folder" && item.isExpanded) {
|
|
170
|
+
this.collapsed.add(item.key);
|
|
171
|
+
this.refresh();
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
const parentKey = item.key.slice(0, item.key.lastIndexOf("\\")) || "\\";
|
|
175
|
+
const idx = this.flatItems.findIndex(i => i.kind === "folder" && i.key === parentKey);
|
|
176
|
+
if (idx >= 0) {
|
|
177
|
+
this.widget.select(idx);
|
|
178
|
+
this.screen.render();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
exports.PipelinesScreen = PipelinesScreen;
|
|
@@ -0,0 +1,194 @@
|
|
|
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.StagesScreen = void 0;
|
|
37
|
+
const child_process_1 = require("child_process");
|
|
38
|
+
const blessed = __importStar(require("blessed"));
|
|
39
|
+
const api_js_1 = require("../lib/api.js");
|
|
40
|
+
const format_js_1 = require("../lib/format.js");
|
|
41
|
+
const cache_js_1 = require("../cache.js");
|
|
42
|
+
class StagesScreen {
|
|
43
|
+
screen;
|
|
44
|
+
ctx;
|
|
45
|
+
colHeader;
|
|
46
|
+
widget;
|
|
47
|
+
pipeline = null;
|
|
48
|
+
runs = [];
|
|
49
|
+
stagesMap = new Map();
|
|
50
|
+
meta = [];
|
|
51
|
+
get footerText() {
|
|
52
|
+
return (" {cyan-fg}↑↓{/} Navigate {cyan-fg}Enter{/} Open run {cyan-fg}r{/} Refresh " +
|
|
53
|
+
"{cyan-fg}b{/} Browser {cyan-fg}p{/} Pipelines {cyan-fg}e{/} Envs {cyan-fg}q{/} Quit");
|
|
54
|
+
}
|
|
55
|
+
constructor(screen, ctx) {
|
|
56
|
+
this.screen = screen;
|
|
57
|
+
this.ctx = ctx;
|
|
58
|
+
this.colHeader = blessed.box({
|
|
59
|
+
parent: screen, top: 1, left: 0, width: "100%", height: 1,
|
|
60
|
+
style: { bg: "black", fg: "cyan", bold: true },
|
|
61
|
+
hidden: true,
|
|
62
|
+
});
|
|
63
|
+
this.widget = blessed.list({
|
|
64
|
+
parent: screen, top: 2, left: 0, width: "100%", height: "100%-4",
|
|
65
|
+
border: { type: "line" }, label: " Stages ",
|
|
66
|
+
tags: true, keys: true, vi: true, scrollable: true,
|
|
67
|
+
scrollbar: { ch: "│", style: { fg: "blue" } },
|
|
68
|
+
style: {
|
|
69
|
+
border: { fg: "magenta" }, selected: { bg: "blue", fg: "white", bold: true },
|
|
70
|
+
item: { fg: "white" }, focus: { border: { fg: "white" } },
|
|
71
|
+
},
|
|
72
|
+
items: [], hidden: true,
|
|
73
|
+
});
|
|
74
|
+
this.registerKeys();
|
|
75
|
+
}
|
|
76
|
+
show(pipeline) {
|
|
77
|
+
this.pipeline = pipeline;
|
|
78
|
+
this.runs = [];
|
|
79
|
+
this.stagesMap = new Map();
|
|
80
|
+
this.meta = [];
|
|
81
|
+
this.colHeader.show();
|
|
82
|
+
this.widget.show();
|
|
83
|
+
this.widget.setItems([]);
|
|
84
|
+
this.widget.setLabel(` Stages: ${pipeline.name} `);
|
|
85
|
+
this.widget.focus();
|
|
86
|
+
this.screen.render();
|
|
87
|
+
this.loadData();
|
|
88
|
+
}
|
|
89
|
+
hide() { this.colHeader.hide(); this.widget.hide(); }
|
|
90
|
+
getPipeline() { return this.pipeline; }
|
|
91
|
+
refresh() {
|
|
92
|
+
if (!this.pipeline)
|
|
93
|
+
return;
|
|
94
|
+
const baseGroups = (0, format_js_1.buildStageBranchSummaries)(this.runs, this.stagesMap);
|
|
95
|
+
this.colHeader.setContent((0, format_js_1.padEnd)(" Stage / Branch", format_js_1.BRANCH_COL + 2) + (0, format_js_1.padEnd)("Plan", format_js_1.PLAN_COL) + "Apply");
|
|
96
|
+
const items = [];
|
|
97
|
+
const meta = [];
|
|
98
|
+
for (let gi = 0; gi < baseGroups.length; gi++) {
|
|
99
|
+
const { displayName, branches } = baseGroups[gi];
|
|
100
|
+
items.push(`{bold} ${(0, format_js_1.padEnd)(displayName, format_js_1.BRANCH_COL)}{/}`);
|
|
101
|
+
meta.push({ kind: "base", displayName });
|
|
102
|
+
let mostRecentBranch;
|
|
103
|
+
let mostRecentTime = "";
|
|
104
|
+
for (const [branch, summary] of branches) {
|
|
105
|
+
if (!(0, format_js_1.branchHasRun)(summary))
|
|
106
|
+
continue;
|
|
107
|
+
const t = summary.applyLatest?.finishTime ?? summary.planLatest?.finishTime ??
|
|
108
|
+
summary.applyPrevOk?.finishTime ?? summary.planPrevOk?.finishTime ?? "";
|
|
109
|
+
if (!mostRecentBranch || t > mostRecentTime) {
|
|
110
|
+
mostRecentTime = t;
|
|
111
|
+
mostRecentBranch = branch;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
for (const [branch, summary] of branches) {
|
|
115
|
+
if (!(0, format_js_1.branchHasRun)(summary))
|
|
116
|
+
continue;
|
|
117
|
+
const dim = branch !== mostRecentBranch;
|
|
118
|
+
const branchText = (0, format_js_1.padEnd)(" " + branch, format_js_1.BRANCH_COL);
|
|
119
|
+
const branchPart = dim ? `{#3a3a3a-fg}${branchText}{/}` : `{bold}${branchText}{/bold}`;
|
|
120
|
+
const planPart = (0, format_js_1.statusCell)(summary.planLatest, summary.planPrevOk, format_js_1.PLAN_COL, false, summary.planPrevActive);
|
|
121
|
+
const applyPart = (0, format_js_1.statusCell)(summary.applyLatest, summary.applyPrevOk, format_js_1.APPLY_COL, false, summary.applyPrevActive);
|
|
122
|
+
const latestRunId = summary.applyLatest?.runId ?? summary.planLatest?.runId;
|
|
123
|
+
items.push(` ${branchPart} ${planPart}${applyPart}`);
|
|
124
|
+
meta.push({ kind: "branch", branch, latestRunId });
|
|
125
|
+
}
|
|
126
|
+
if (gi < baseGroups.length - 1) {
|
|
127
|
+
items.push("");
|
|
128
|
+
meta.push({ kind: "separator" });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
this.meta = meta;
|
|
132
|
+
this.widget.setItems(items);
|
|
133
|
+
this.widget.setLabel(` Stages: ${this.pipeline.name} (${this.runs.length} runs) `);
|
|
134
|
+
this.screen.render();
|
|
135
|
+
}
|
|
136
|
+
async loadData() {
|
|
137
|
+
if (!this.pipeline)
|
|
138
|
+
return;
|
|
139
|
+
this.ctx.setStatus("Loading pipeline runs…", 0);
|
|
140
|
+
try {
|
|
141
|
+
const token = await this.ctx.getToken();
|
|
142
|
+
this.runs = await (0, api_js_1.fetchPipelineRuns)(this.ctx.org, this.ctx.project, this.pipeline.id, token);
|
|
143
|
+
this.ctx.setStatus(`${this.runs.length} runs — loading timelines…`, 0);
|
|
144
|
+
this.refresh();
|
|
145
|
+
const BATCH = 10;
|
|
146
|
+
for (let i = 0; i < this.runs.length; i += BATCH) {
|
|
147
|
+
await Promise.all(this.runs.slice(i, i + BATCH).map(async (run) => {
|
|
148
|
+
const stages = await (0, api_js_1.fetchRunStages)(this.ctx.org, this.ctx.project, run.id, token);
|
|
149
|
+
this.stagesMap.set(run.id, stages);
|
|
150
|
+
}));
|
|
151
|
+
this.refresh();
|
|
152
|
+
this.ctx.setStatus(`Timelines ${Math.min(i + BATCH, this.runs.length)}/${this.runs.length}…`, 0);
|
|
153
|
+
}
|
|
154
|
+
this.ctx.setStatus("", 0);
|
|
155
|
+
}
|
|
156
|
+
catch (e) {
|
|
157
|
+
this.ctx.setStatus(`Error: ${e.message.slice(0, 80)}`, 10_000);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
registerKeys() {
|
|
161
|
+
this.widget.key("p", () => this.ctx.navigate({ view: "pipelines" }));
|
|
162
|
+
this.widget.key("e", () => this.ctx.navigate({ view: "environments" }));
|
|
163
|
+
this.widget.key("escape", () => this.ctx.goBack());
|
|
164
|
+
this.widget.key("r", () => {
|
|
165
|
+
if (!this.pipeline)
|
|
166
|
+
return;
|
|
167
|
+
const { org, project } = this.ctx;
|
|
168
|
+
(0, cache_js_1.clearByPrefix)(`runs_${org}_${project}_${this.pipeline.id}`);
|
|
169
|
+
(0, cache_js_1.clearByPrefix)(`stages_${org}_${project}`);
|
|
170
|
+
this.runs = [];
|
|
171
|
+
this.stagesMap = new Map();
|
|
172
|
+
this.ctx.setStatus("Refreshing…", 0);
|
|
173
|
+
this.loadData();
|
|
174
|
+
});
|
|
175
|
+
this.widget.key("b", () => {
|
|
176
|
+
if (!this.pipeline)
|
|
177
|
+
return;
|
|
178
|
+
const { org, project } = this.ctx;
|
|
179
|
+
const url = `https://dev.azure.com/${org}/${project}/_build?definitionId=${this.pipeline.id}&_a=summary`;
|
|
180
|
+
try {
|
|
181
|
+
(0, child_process_1.spawn)("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref();
|
|
182
|
+
}
|
|
183
|
+
catch { }
|
|
184
|
+
});
|
|
185
|
+
this.widget.key("enter", () => {
|
|
186
|
+
const sel = this.widget.selected ?? 0;
|
|
187
|
+
const m = this.meta[sel];
|
|
188
|
+
if (!m || m.kind !== "branch" || !m.latestRunId)
|
|
189
|
+
return;
|
|
190
|
+
this.ctx.navigate({ view: "pipelineRun", buildId: String(m.latestRunId) });
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
exports.StagesScreen = StagesScreen;
|
package/dist/signalr.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Azure DevOps SignalR client (ASP.NET SignalR 1.x over WebSocket)
|
|
3
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
4
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
|
+
};
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.connectSignalR = connectSignalR;
|
|
8
|
+
const https_1 = __importDefault(require("https"));
|
|
9
|
+
const ws_1 = __importDefault(require("ws"));
|
|
10
|
+
const url_1 = require("url");
|
|
11
|
+
const fs_1 = __importDefault(require("fs"));
|
|
12
|
+
// ── HTTP helper (standalone, no shared state with index.ts) ──────────────────
|
|
13
|
+
function getJson(url, token, redirectsLeft = 5) {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
const u = new url_1.URL(url);
|
|
16
|
+
https_1.default
|
|
17
|
+
.get({ hostname: u.hostname, path: u.pathname + u.search,
|
|
18
|
+
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" } }, (res) => {
|
|
19
|
+
const status = res.statusCode ?? 0;
|
|
20
|
+
if (status >= 300 && status < 400) {
|
|
21
|
+
const loc = res.headers["location"];
|
|
22
|
+
res.resume();
|
|
23
|
+
if (!loc || redirectsLeft <= 0)
|
|
24
|
+
return reject(new Error(`HTTP ${status}: redirect limit reached (Location: ${loc ?? "none"})`));
|
|
25
|
+
const next = loc.startsWith("http") ? loc : `https://${u.hostname}${loc}`;
|
|
26
|
+
return resolve(getJson(next, token, redirectsLeft - 1));
|
|
27
|
+
}
|
|
28
|
+
let data = "";
|
|
29
|
+
res.on("data", (c) => (data += c));
|
|
30
|
+
res.on("end", () => {
|
|
31
|
+
if (status >= 400)
|
|
32
|
+
return reject(new Error(`HTTP ${status}: ${data.slice(0, 200)}`));
|
|
33
|
+
try {
|
|
34
|
+
resolve(JSON.parse(data));
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
reject(new Error(`JSON: ${e.message}`));
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
})
|
|
41
|
+
.on("error", reject);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
// ── Main export ───────────────────────────────────────────────────────────────
|
|
45
|
+
// projectId: the project GUID (e.g. from `az devops project show --query id`)
|
|
46
|
+
async function connectSignalR(org, projectId, token, onEvent, onStatus, onClose) {
|
|
47
|
+
const CONNECTION_DATA = JSON.stringify([
|
|
48
|
+
{ name: "builddetailhub" },
|
|
49
|
+
{ name: "taskagentpoolhub" },
|
|
50
|
+
]);
|
|
51
|
+
const encodedData = encodeURIComponent(CONNECTION_DATA);
|
|
52
|
+
const orgEncoded = encodeURIComponent(org);
|
|
53
|
+
// ── 1. Instance ID (org-level, used for negotiate + start) ────────────────
|
|
54
|
+
onStatus("SignalR: fetching instance id…");
|
|
55
|
+
const { instanceId } = await getJson(`https://dev.azure.com/${orgEncoded}/_apis/connectionData` +
|
|
56
|
+
`?connectOptions=0&lastChangeId=-1&lastChangeId64=-1`, token);
|
|
57
|
+
// ── 2. Negotiate (uses instanceId → returns contextToken) ─────────────────
|
|
58
|
+
onStatus("SignalR: negotiating…");
|
|
59
|
+
const negotiated = await getJson(`https://dev.azure.com/_signalr/${orgEncoded}/_apis/${instanceId}/signalr/negotiate` +
|
|
60
|
+
`?transport=webSockets&clientProtocol=1.5&connectionData=${encodedData}&_=${Date.now()}`, token);
|
|
61
|
+
fs_1.default.writeFileSync("signalr-negotiate.json", JSON.stringify(negotiated, null, 2));
|
|
62
|
+
// contextToken comes from the negotiate Url field: /_signalr/_apis/{guid}/signalr
|
|
63
|
+
const contextToken = negotiated.ContextToken ??
|
|
64
|
+
negotiated.Url?.match(/\/_apis\/([0-9a-f-]{36})\//i)?.[1];
|
|
65
|
+
// ── 3. Connect WebSocket (uses projectId in path, contextToken as param) ──
|
|
66
|
+
onStatus("SignalR: connecting…");
|
|
67
|
+
const wsUrl = `wss://dev.azure.com/_signalr/${orgEncoded}/_apis/${projectId}/signalr/connect` +
|
|
68
|
+
`?transport=webSockets&clientProtocol=1.5` +
|
|
69
|
+
`&connectionToken=${encodeURIComponent(negotiated.ConnectionToken)}` +
|
|
70
|
+
`&connectionData=${encodedData}` +
|
|
71
|
+
(contextToken ? `&contextToken=${encodeURIComponent(contextToken)}` : "") +
|
|
72
|
+
`&tid=${Math.floor(Math.random() * 20)}`;
|
|
73
|
+
const ws = new ws_1.default(wsUrl, {
|
|
74
|
+
headers: {
|
|
75
|
+
Authorization: `Bearer ${token}`,
|
|
76
|
+
"Sec-WebSocket-Protocol": `Bearer, ${token}`,
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
await new Promise((resolve, reject) => {
|
|
80
|
+
ws.once("open", resolve);
|
|
81
|
+
ws.once("error", reject);
|
|
82
|
+
});
|
|
83
|
+
// ── 4. Start (uses instanceId) ────────────────────────────────────────────
|
|
84
|
+
try {
|
|
85
|
+
await getJson(`https://dev.azure.com/_signalr/${orgEncoded}/_apis/${instanceId}/signalr/start` +
|
|
86
|
+
`?transport=webSockets&clientProtocol=1.5` +
|
|
87
|
+
`&connectionToken=${encodeURIComponent(negotiated.ConnectionToken)}` +
|
|
88
|
+
`&connectionData=${encodedData}&_=${Date.now()}`, token);
|
|
89
|
+
}
|
|
90
|
+
catch { /* start returns empty body, not JSON */ }
|
|
91
|
+
onStatus("SignalR: connected ●");
|
|
92
|
+
// ── Message handler ────────────────────────────────────────────────────────
|
|
93
|
+
ws.on("message", (raw) => {
|
|
94
|
+
const text = raw.toString();
|
|
95
|
+
fs_1.default.appendFileSync("signalr-messages.jsonl", text + "\n");
|
|
96
|
+
if (text === "{}")
|
|
97
|
+
return;
|
|
98
|
+
try {
|
|
99
|
+
const frame = JSON.parse(text);
|
|
100
|
+
for (const msg of frame.M ?? [])
|
|
101
|
+
onEvent({ hub: msg.H ?? "", method: msg.M ?? "", args: msg.A ?? [] });
|
|
102
|
+
}
|
|
103
|
+
catch { /* malformed frame */ }
|
|
104
|
+
});
|
|
105
|
+
ws.on("close", () => { onStatus("SignalR: disconnected"); onClose?.(); });
|
|
106
|
+
ws.on("error", (e) => onStatus(`SignalR error: ${e.message}`));
|
|
107
|
+
// ── Handle ─────────────────────────────────────────────────────────────────
|
|
108
|
+
let invId = 0;
|
|
109
|
+
return {
|
|
110
|
+
invoke(hub, method, ...args) {
|
|
111
|
+
if (ws.readyState !== ws_1.default.OPEN)
|
|
112
|
+
return;
|
|
113
|
+
ws.send(JSON.stringify({ H: hub, M: method, A: args, I: String(invId++) }));
|
|
114
|
+
},
|
|
115
|
+
close() { ws.close(); },
|
|
116
|
+
};
|
|
117
|
+
}
|