azdo-cli 0.5.0-030-auth-diagnostics.530 → 0.5.0-030-auth-diagnostics.537
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 +2 -1
- package/dist/index.js +201 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,7 +17,8 @@ Azure DevOps CLI focused on work item read/write workflows.
|
|
|
17
17
|
- Check branch pull request status, open PRs to `develop`, list PR comment threads for any PR (`--pr-number`), and resolve/reopen threads from the CLI (`pr`)
|
|
18
18
|
- Persist org/project/default fields in local config (`config`)
|
|
19
19
|
- List all fields of a work item (`list-fields`)
|
|
20
|
-
- Authenticate per Azure DevOps organization with `azdo auth login` — OAuth (Microsoft Entra) by default, or a Personal Access Token via `--use-pat` (or the `AZDO_PAT` env var). Credentials are stored in the OS credential store. Inspect with `azdo auth status`, remove with `azdo auth logout`. See [docs/authentication.md](docs/authentication.md).
|
|
20
|
+
- Authenticate per Azure DevOps organization with `azdo auth login` — OAuth (Microsoft Entra) by default, or a Personal Access Token via `--use-pat` (or the `AZDO_PAT` env var). Credentials are stored in the OS credential store. Inspect with `azdo auth status`, remove with `azdo auth logout`. Diagnose auth problems with `azdo auth diagnose`. See [docs/authentication.md](docs/authentication.md).
|
|
21
|
+
- Trace all HTTP requests to a local file with `--trace <filepath>` (sensitive headers and tokens are automatically redacted).
|
|
21
22
|
|
|
22
23
|
## Installation
|
|
23
24
|
|
package/dist/index.js
CHANGED
|
@@ -51,6 +51,78 @@ var version = pkg.version;
|
|
|
51
51
|
// src/commands/get-item.ts
|
|
52
52
|
import { Command } from "commander";
|
|
53
53
|
|
|
54
|
+
// src/services/trace-writer.ts
|
|
55
|
+
import { openSync, writeSync, closeSync } from "fs";
|
|
56
|
+
import { platform } from "os";
|
|
57
|
+
var REDACTED = "[REDACTED]";
|
|
58
|
+
var SENSITIVE_HEADER = /^(authorization|x-.*token)$/i;
|
|
59
|
+
var SENSITIVE_QUERY_PARAM = /^(token|pat)$/i;
|
|
60
|
+
var SENSITIVE_BODY_FIELD = /^(token|accessToken|pat)$/;
|
|
61
|
+
function redactHeaders(headers) {
|
|
62
|
+
const out = {};
|
|
63
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
64
|
+
out[key] = SENSITIVE_HEADER.test(key) ? REDACTED : value;
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
function redactUrl(url) {
|
|
69
|
+
try {
|
|
70
|
+
const u = new URL(url);
|
|
71
|
+
for (const [key] of u.searchParams.entries()) {
|
|
72
|
+
if (SENSITIVE_QUERY_PARAM.test(key)) {
|
|
73
|
+
u.searchParams.set(key, REDACTED);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return u.toString();
|
|
77
|
+
} catch {
|
|
78
|
+
return url;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function redactBody(body) {
|
|
82
|
+
if (body === null) return null;
|
|
83
|
+
try {
|
|
84
|
+
const parsed = JSON.parse(body);
|
|
85
|
+
let changed = false;
|
|
86
|
+
const redacted = { ...parsed };
|
|
87
|
+
for (const key of Object.keys(parsed)) {
|
|
88
|
+
if (SENSITIVE_BODY_FIELD.test(key)) {
|
|
89
|
+
redacted[key] = REDACTED;
|
|
90
|
+
changed = true;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return changed ? JSON.stringify(redacted) : body;
|
|
94
|
+
} catch {
|
|
95
|
+
return body;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
var TraceWriter = class {
|
|
99
|
+
fd;
|
|
100
|
+
constructor(filepath) {
|
|
101
|
+
const mode = platform() === "win32" ? void 0 : 384;
|
|
102
|
+
this.fd = openSync(filepath, "a", mode);
|
|
103
|
+
}
|
|
104
|
+
append(entry) {
|
|
105
|
+
const line = JSON.stringify(entry) + "\n\n";
|
|
106
|
+
writeSync(this.fd, line);
|
|
107
|
+
}
|
|
108
|
+
close() {
|
|
109
|
+
closeSync(this.fd);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
var activeWriter = null;
|
|
113
|
+
function initTraceWriter(filepath) {
|
|
114
|
+
try {
|
|
115
|
+
activeWriter = new TraceWriter(filepath);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
118
|
+
process.stderr.write(`Warning: could not open trace file "${filepath}": ${msg}
|
|
119
|
+
`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function getActiveTraceWriter() {
|
|
123
|
+
return activeWriter;
|
|
124
|
+
}
|
|
125
|
+
|
|
54
126
|
// src/services/azdo-client.ts
|
|
55
127
|
var DEFAULT_FIELDS = [
|
|
56
128
|
"System.Title",
|
|
@@ -74,12 +146,48 @@ function authHeaders(credentialOrPat) {
|
|
|
74
146
|
const token = Buffer.from(`:${credentialOrPat.pat}`).toString("base64");
|
|
75
147
|
return { Authorization: `Basic ${token}` };
|
|
76
148
|
}
|
|
149
|
+
async function fetchRaw(url, init) {
|
|
150
|
+
let response;
|
|
151
|
+
try {
|
|
152
|
+
response = await fetch(url, init);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
throw new Error(`NETWORK_ERROR: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
155
|
+
}
|
|
156
|
+
const body = await response.text();
|
|
157
|
+
return { status: response.status, body };
|
|
158
|
+
}
|
|
77
159
|
async function fetchWithErrors(url, init) {
|
|
160
|
+
const writer = getActiveTraceWriter();
|
|
78
161
|
let response;
|
|
79
162
|
try {
|
|
80
163
|
response = await fetch(url, init);
|
|
81
|
-
} catch {
|
|
82
|
-
throw new Error("NETWORK_ERROR");
|
|
164
|
+
} catch (err) {
|
|
165
|
+
throw new Error("NETWORK_ERROR", { cause: err });
|
|
166
|
+
}
|
|
167
|
+
if (writer) {
|
|
168
|
+
const reqHeaders = redactHeaders(init.headers ?? {});
|
|
169
|
+
const reqBody = typeof init.body === "string" ? redactBody(init.body) : null;
|
|
170
|
+
let responseBody = "";
|
|
171
|
+
const clone = response.clone();
|
|
172
|
+
try {
|
|
173
|
+
responseBody = await clone.text();
|
|
174
|
+
} catch {
|
|
175
|
+
}
|
|
176
|
+
const respHeaders = {};
|
|
177
|
+
response.headers.forEach((v, k) => {
|
|
178
|
+
respHeaders[k] = v;
|
|
179
|
+
});
|
|
180
|
+
const entry = {
|
|
181
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
182
|
+
method: (init.method ?? "GET").toUpperCase(),
|
|
183
|
+
url: redactUrl(url),
|
|
184
|
+
requestHeaders: reqHeaders,
|
|
185
|
+
requestBody: reqBody ?? null,
|
|
186
|
+
responseStatus: response.status,
|
|
187
|
+
responseHeaders: respHeaders,
|
|
188
|
+
responseBody
|
|
189
|
+
};
|
|
190
|
+
writer.append(entry);
|
|
83
191
|
}
|
|
84
192
|
if (response.status === 401) throw new Error("AUTH_FAILED");
|
|
85
193
|
if (response.status === 403) throw new Error("PERMISSION_DENIED");
|
|
@@ -1561,6 +1669,71 @@ function createClearPatCommand() {
|
|
|
1561
1669
|
|
|
1562
1670
|
// src/commands/auth.ts
|
|
1563
1671
|
import { Command as Command3 } from "commander";
|
|
1672
|
+
|
|
1673
|
+
// src/services/auth-diagnostics.ts
|
|
1674
|
+
async function runConnectivityTest(org, cred) {
|
|
1675
|
+
const url = `https://dev.azure.com/${encodeURIComponent(org)}/_apis/projects?api-version=7.1&$top=1`;
|
|
1676
|
+
let result;
|
|
1677
|
+
try {
|
|
1678
|
+
result = await fetchRaw(url, { headers: authHeaders(cred) });
|
|
1679
|
+
} catch (err) {
|
|
1680
|
+
return { status: "failed", error: err instanceof Error ? err.message : String(err) };
|
|
1681
|
+
}
|
|
1682
|
+
if (result.status >= 200 && result.status < 300) {
|
|
1683
|
+
return { status: "ok", error: null };
|
|
1684
|
+
}
|
|
1685
|
+
let error = `HTTP ${result.status}`;
|
|
1686
|
+
try {
|
|
1687
|
+
const parsed = JSON.parse(result.body);
|
|
1688
|
+
if (typeof parsed.message === "string" && parsed.message.trim() !== "") {
|
|
1689
|
+
error = parsed.message.trim();
|
|
1690
|
+
}
|
|
1691
|
+
} catch {
|
|
1692
|
+
}
|
|
1693
|
+
return { status: "failed", error };
|
|
1694
|
+
}
|
|
1695
|
+
async function diagnoseAuth(org, project, resolveCredential) {
|
|
1696
|
+
const cred = await resolveCredential(org);
|
|
1697
|
+
if (cred === null) {
|
|
1698
|
+
return {
|
|
1699
|
+
authType: "none",
|
|
1700
|
+
credentialSource: null,
|
|
1701
|
+
org,
|
|
1702
|
+
project,
|
|
1703
|
+
connectivityStatus: "no-credentials",
|
|
1704
|
+
connectivityError: null
|
|
1705
|
+
};
|
|
1706
|
+
}
|
|
1707
|
+
const connectivity = await runConnectivityTest(org, cred);
|
|
1708
|
+
const sourceLabel = cred.source === "env" ? `env:${process.env.AZDO_PAT ? "AZDO_PAT" : "dotenv"}` : "credential-store";
|
|
1709
|
+
return {
|
|
1710
|
+
authType: cred.kind ?? "pat",
|
|
1711
|
+
credentialSource: sourceLabel,
|
|
1712
|
+
org,
|
|
1713
|
+
project,
|
|
1714
|
+
connectivityStatus: connectivity.status,
|
|
1715
|
+
connectivityError: connectivity.error
|
|
1716
|
+
};
|
|
1717
|
+
}
|
|
1718
|
+
function formatDiagnosticReport(report, json) {
|
|
1719
|
+
if (json) {
|
|
1720
|
+
return JSON.stringify(report, null, 2);
|
|
1721
|
+
}
|
|
1722
|
+
const connectivityLine = report.connectivityStatus === "ok" ? "OK" : report.connectivityStatus === "no-credentials" ? "no credentials found" : "FAILED";
|
|
1723
|
+
const lines = [
|
|
1724
|
+
`Auth type: ${report.authType}`,
|
|
1725
|
+
`Source: ${report.credentialSource ?? "(none)"}`,
|
|
1726
|
+
`Org: ${report.org}`,
|
|
1727
|
+
`Project: ${report.project ?? "(not set)"}`,
|
|
1728
|
+
`Connectivity: ${connectivityLine}`
|
|
1729
|
+
];
|
|
1730
|
+
if (report.connectivityStatus === "failed" && report.connectivityError !== null) {
|
|
1731
|
+
lines.push(`Error: ${report.connectivityError}`);
|
|
1732
|
+
}
|
|
1733
|
+
return lines.join("\n");
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
// src/commands/auth.ts
|
|
1564
1737
|
async function readStdinToString() {
|
|
1565
1738
|
const chunks = [];
|
|
1566
1739
|
for await (const chunk of process.stdin) {
|
|
@@ -1982,6 +2155,25 @@ Note: \`azdo auth\` (no subcommand) preserves the legacy PAT-prompt entry point;
|
|
|
1982
2155
|
const globals = logoutCmd.optsWithGlobals();
|
|
1983
2156
|
await handleLogout(options, globals.org);
|
|
1984
2157
|
});
|
|
2158
|
+
const diagnoseCmd = command.command("diagnose").description("Show auth type, credential source, org, and live connectivity test result").option("--org <name>", "Azure DevOps organization (overrides context resolution)").option("--project <name>", "Azure DevOps project (optional context)").option("--json", "emit JSON instead of human-readable text", false);
|
|
2159
|
+
diagnoseCmd.action(async (options) => {
|
|
2160
|
+
const globals = diagnoseCmd.optsWithGlobals();
|
|
2161
|
+
const orgName = options.org ?? globals.org;
|
|
2162
|
+
const resolved = resolveOrg({ org: orgName });
|
|
2163
|
+
if (!resolved) {
|
|
2164
|
+
process.stderr.write(`${formatResolutionError()}
|
|
2165
|
+
`);
|
|
2166
|
+
process.exitCode = 1;
|
|
2167
|
+
return;
|
|
2168
|
+
}
|
|
2169
|
+
const report = await diagnoseAuth(resolved.org, options.project ?? null, resolveAuthCredential);
|
|
2170
|
+
const output = formatDiagnosticReport(report, options.json ?? false);
|
|
2171
|
+
process.stdout.write(`${output}
|
|
2172
|
+
`);
|
|
2173
|
+
if (report.connectivityStatus === "failed") {
|
|
2174
|
+
process.exitCode = 1;
|
|
2175
|
+
}
|
|
2176
|
+
});
|
|
1985
2177
|
return command;
|
|
1986
2178
|
}
|
|
1987
2179
|
|
|
@@ -5203,6 +5395,7 @@ process.stderr.on("error", exitOnEpipe);
|
|
|
5203
5395
|
var program = new Command17();
|
|
5204
5396
|
program.name("azdo").description("Azure DevOps CLI tool").version(version, "-v, --version");
|
|
5205
5397
|
program.option("--no-update-check", "Skip the check for a newer published version");
|
|
5398
|
+
program.option("--trace <filepath>", "Append redacted HTTP request/response trace to a file (owner-read-only permissions)");
|
|
5206
5399
|
program.addCommand(createGetItemCommand());
|
|
5207
5400
|
program.addCommand(createAuthCommand());
|
|
5208
5401
|
program.addCommand(createClearPatCommand());
|
|
@@ -5220,6 +5413,12 @@ program.addCommand(createCommentsCommand());
|
|
|
5220
5413
|
program.addCommand(createDownloadAttachmentCommand());
|
|
5221
5414
|
program.addCommand(createRelationsCommand());
|
|
5222
5415
|
program.showHelpAfterError();
|
|
5416
|
+
program.hook("preAction", () => {
|
|
5417
|
+
const { trace } = program.opts();
|
|
5418
|
+
if (trace) {
|
|
5419
|
+
initTraceWriter(trace);
|
|
5420
|
+
}
|
|
5421
|
+
});
|
|
5223
5422
|
program.hook("postAction", async () => {
|
|
5224
5423
|
const notice = await getUpdateNotice({ enabled: program.opts().updateCheck });
|
|
5225
5424
|
if (notice) {
|