azdo-cli 0.10.0-develop.512 → 0.10.0-develop.543

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.
Files changed (3) hide show
  1. package/README.md +7 -1
  2. package/dist/index.js +293 -2
  3. package/package.json +3 -3
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
 
@@ -62,6 +63,11 @@ azdo pr status # PR checks (status + branch policies +
62
63
  azdo pr comment-resolve 17 --pr-number 64 # idempotent: exit 0 even when already resolved
63
64
  azdo pr comment-reopen 17 --pr-number 64
64
65
 
66
+ # Reply to a PR comment thread
67
+ azdo pr comments reply 148 "Great suggestion, I'll address it." # human-readable output
68
+ azdo pr comments reply 148 "Done." --pr-number 64 --json # JSON: { pullRequestId, threadId, commentId, content }
69
+ azdo pr comment-reply 148 "Done." --pr-number 64 # flat alias, identical behaviour
70
+
65
71
  # Pipelines — list, inspect runs, wait (exit code = result), start
66
72
  azdo pipeline list --filter ci
67
73
  azdo pipeline get-runs 12 --branch develop --limit 1
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,79 @@ 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 envVarName = process.env.AZDO_PAT ? "AZDO_PAT" : "dotenv";
1709
+ const sourceLabel = cred.source === "env" ? `env:${envVarName}` : "credential-store";
1710
+ return {
1711
+ authType: cred.kind ?? "pat",
1712
+ credentialSource: sourceLabel,
1713
+ org,
1714
+ project,
1715
+ connectivityStatus: connectivity.status,
1716
+ connectivityError: connectivity.error
1717
+ };
1718
+ }
1719
+ function formatDiagnosticReport(report, json) {
1720
+ if (json) {
1721
+ return JSON.stringify(report, null, 2);
1722
+ }
1723
+ let connectivityLine;
1724
+ if (report.connectivityStatus === "ok") {
1725
+ connectivityLine = "OK";
1726
+ } else if (report.connectivityStatus === "no-credentials") {
1727
+ connectivityLine = "no credentials found";
1728
+ } else {
1729
+ connectivityLine = "FAILED";
1730
+ }
1731
+ const lines = [
1732
+ `Auth type: ${report.authType}`,
1733
+ `Source: ${report.credentialSource ?? "(none)"}`,
1734
+ `Org: ${report.org}`,
1735
+ `Project: ${report.project ?? "(not set)"}`,
1736
+ `Connectivity: ${connectivityLine}`
1737
+ ];
1738
+ if (report.connectivityStatus === "failed" && report.connectivityError !== null) {
1739
+ lines.push(`Error: ${report.connectivityError}`);
1740
+ }
1741
+ return lines.join("\n");
1742
+ }
1743
+
1744
+ // src/commands/auth.ts
1564
1745
  async function readStdinToString() {
1565
1746
  const chunks = [];
1566
1747
  for await (const chunk of process.stdin) {
@@ -1982,6 +2163,25 @@ Note: \`azdo auth\` (no subcommand) preserves the legacy PAT-prompt entry point;
1982
2163
  const globals = logoutCmd.optsWithGlobals();
1983
2164
  await handleLogout(options, globals.org);
1984
2165
  });
2166
+ 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);
2167
+ diagnoseCmd.action(async (options) => {
2168
+ const globals = diagnoseCmd.optsWithGlobals();
2169
+ const orgName = options.org ?? globals.org;
2170
+ const resolved = resolveOrg({ org: orgName });
2171
+ if (!resolved) {
2172
+ process.stderr.write(`${formatResolutionError()}
2173
+ `);
2174
+ process.exitCode = 1;
2175
+ return;
2176
+ }
2177
+ const report = await diagnoseAuth(resolved.org, options.project ?? null, resolveAuthCredential);
2178
+ const output = formatDiagnosticReport(report, options.json ?? false);
2179
+ process.stdout.write(`${output}
2180
+ `);
2181
+ if (report.connectivityStatus === "failed") {
2182
+ process.exitCode = 1;
2183
+ }
2184
+ });
1985
2185
  return command;
1986
2186
  }
1987
2187
 
@@ -3188,6 +3388,30 @@ async function getPullRequestThreads(context, repo, cred, prId) {
3188
3388
  const data = await readJsonResponse(response);
3189
3389
  return data.value.map(mapThread).filter((thread) => thread !== null);
3190
3390
  }
3391
+ function buildThreadCommentUrl(context, repo, prId, threadId) {
3392
+ const url = new URL(
3393
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}/threads/${threadId}/comments`
3394
+ );
3395
+ url.searchParams.set("api-version", "7.1");
3396
+ return url;
3397
+ }
3398
+ async function postThreadComment(context, repo, cred, prId, threadId, content) {
3399
+ const response = await fetchWithErrors(buildThreadCommentUrl(context, repo, prId, threadId).toString(), {
3400
+ method: "POST",
3401
+ headers: {
3402
+ ...authHeaders(cred),
3403
+ "Content-Type": "application/json"
3404
+ },
3405
+ body: JSON.stringify({ content, parentCommentId: 0, commentType: 1 })
3406
+ });
3407
+ const data = await readJsonResponse(response);
3408
+ return {
3409
+ id: data.id,
3410
+ author: data.author?.displayName ?? null,
3411
+ content: data.content ?? content,
3412
+ publishedAt: data.publishedDate ?? null
3413
+ };
3414
+ }
3191
3415
 
3192
3416
  // src/commands/pr.ts
3193
3417
  function parsePositivePrNumber(raw) {
@@ -3539,6 +3763,7 @@ function createPrCommentsCommand() {
3539
3763
  handlePrCommandError(err, context, "read");
3540
3764
  }
3541
3765
  });
3766
+ command.addCommand(createPrCommentsReplyCommand());
3542
3767
  return command;
3543
3768
  }
3544
3769
  async function resolveThreadTarget(threadIdRaw, options) {
@@ -3657,6 +3882,64 @@ function createPrCommentReopenCommand() {
3657
3882
  });
3658
3883
  return command;
3659
3884
  }
3885
+ async function runCommentReply(threadIdRaw, text, options) {
3886
+ let context;
3887
+ try {
3888
+ const trimmedText = text.trim();
3889
+ if (!trimmedText) {
3890
+ writeError("Reply text must not be empty.");
3891
+ return;
3892
+ }
3893
+ const target = await resolveThreadTarget(threadIdRaw, options);
3894
+ if (target === null) {
3895
+ return;
3896
+ }
3897
+ context = target.context;
3898
+ const threads = await getPullRequestThreads(target.context, target.repo, target.pat, target.pullRequest.id);
3899
+ const thread = threads.find((t) => t.id === target.threadId);
3900
+ if (!thread) {
3901
+ writeError(`Thread #${target.threadId} not found on pull request #${target.pullRequest.id}.`);
3902
+ return;
3903
+ }
3904
+ const posted = await postThreadComment(
3905
+ target.context,
3906
+ target.repo,
3907
+ target.pat,
3908
+ target.pullRequest.id,
3909
+ target.threadId,
3910
+ trimmedText
3911
+ );
3912
+ const result = {
3913
+ pullRequestId: target.pullRequest.id,
3914
+ threadId: target.threadId,
3915
+ commentId: posted.id,
3916
+ content: posted.content
3917
+ };
3918
+ if (options.json) {
3919
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
3920
+ `);
3921
+ return;
3922
+ }
3923
+ process.stdout.write(`Reply posted to thread #${target.threadId} on pull request #${target.pullRequest.id}.
3924
+ `);
3925
+ } catch (err) {
3926
+ handlePrCommandError(err, context, "write");
3927
+ }
3928
+ }
3929
+ function createPrCommentsReplyCommand() {
3930
+ const command = new Command12("reply");
3931
+ configureUnwrappedHelp(command).description("Post a reply to a pull request comment thread").argument("<threadId>", "numeric id of the thread to reply to").argument("<text>", "text of the reply").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--pr-number <N>", PR_NUMBER_HELP).option("--json", "output JSON").action(async (threadIdRaw, text, options) => {
3932
+ await runCommentReply(threadIdRaw, text, options);
3933
+ });
3934
+ return command;
3935
+ }
3936
+ function createPrCommentReplyCommand() {
3937
+ const command = new Command12("comment-reply");
3938
+ configureUnwrappedHelp(command).description('Post a reply to a pull request comment thread (alias of "azdo pr comments reply")').argument("<threadId>", "numeric id of the thread to reply to").argument("<text>", "text of the reply").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--pr-number <N>", PR_NUMBER_HELP).option("--json", "output JSON").action(async (threadIdRaw, text, options) => {
3939
+ await runCommentReply(threadIdRaw, text, options);
3940
+ });
3941
+ return command;
3942
+ }
3660
3943
  function createPrCommand() {
3661
3944
  const command = new Command12("pr");
3662
3945
  command.description("Manage Azure DevOps pull requests");
@@ -3665,6 +3948,7 @@ function createPrCommand() {
3665
3948
  command.addCommand(createPrCommentsCommand());
3666
3949
  command.addCommand(createPrCommentResolveCommand());
3667
3950
  command.addCommand(createPrCommentReopenCommand());
3951
+ command.addCommand(createPrCommentReplyCommand());
3668
3952
  return command;
3669
3953
  }
3670
3954
 
@@ -5119,6 +5403,7 @@ process.stderr.on("error", exitOnEpipe);
5119
5403
  var program = new Command17();
5120
5404
  program.name("azdo").description("Azure DevOps CLI tool").version(version, "-v, --version");
5121
5405
  program.option("--no-update-check", "Skip the check for a newer published version");
5406
+ program.option("--trace <filepath>", "Append redacted HTTP request/response trace to a file (owner-read-only permissions)");
5122
5407
  program.addCommand(createGetItemCommand());
5123
5408
  program.addCommand(createAuthCommand());
5124
5409
  program.addCommand(createClearPatCommand());
@@ -5136,6 +5421,12 @@ program.addCommand(createCommentsCommand());
5136
5421
  program.addCommand(createDownloadAttachmentCommand());
5137
5422
  program.addCommand(createRelationsCommand());
5138
5423
  program.showHelpAfterError();
5424
+ program.hook("preAction", () => {
5425
+ const { trace } = program.opts();
5426
+ if (trace) {
5427
+ initTraceWriter(trace);
5428
+ }
5429
+ });
5139
5430
  program.hook("postAction", async () => {
5140
5431
  const notice = await getUpdateNotice({ enabled: program.opts().updateCheck });
5141
5432
  if (notice) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "azdo-cli",
3
- "version": "0.10.0-develop.512",
3
+ "version": "0.10.0-develop.543",
4
4
  "description": "Azure DevOps CLI tool",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,8 +14,8 @@
14
14
  "lint": "eslint src/",
15
15
  "typecheck": "tsc --noEmit",
16
16
  "format": "prettier --check src/",
17
- "test": "npm run build && vitest run tests/unit tests/integration",
18
- "test:unit": "npm run build && vitest run tests/unit",
17
+ "test": "npm run typecheck && npm run lint && npm run build && vitest run tests/unit tests/integration",
18
+ "test:unit": "npm run typecheck && npm run lint && npm run build && vitest run tests/unit",
19
19
  "test:integration": "npm run build && vitest run tests/integration",
20
20
  "test:integration:full": "bash scripts/setup-keyring.sh && npm run test:integration"
21
21
  },