azdo-cli 0.5.0-feature-fixer-and-gitflow-skills.261 → 0.5.0-feature-tech-stack-030-auth-diagnostics.546

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/dist/index.js CHANGED
@@ -1,7 +1,44 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ AZDO_RESOURCE_ID,
4
+ CredentialMissingError,
5
+ CredentialStoreUnavailableError,
6
+ SETTINGS,
7
+ appendAuthAuditEvent,
8
+ buildScopeString,
9
+ copyOrgScope,
10
+ defaultScopes,
11
+ deleteOrgScope,
12
+ deletePat,
13
+ firstPartyShippedScopes,
14
+ getConfigValue,
15
+ getOrgScopedValue,
16
+ getPat,
17
+ getStoredCredential,
18
+ listOrgsWithStoredPat,
19
+ loadConfig,
20
+ maskedDisplay,
21
+ moveOrgScope,
22
+ normalizePat,
23
+ openUrl,
24
+ probeBackend,
25
+ readAuditEvents,
26
+ readTokenResponse,
27
+ refreshIfNeeded,
28
+ resolveOAuthConfig,
29
+ resolveScopedConfig,
30
+ runAuthCodeFlow,
31
+ setConfigValue,
32
+ setOrgScopedValue,
33
+ storeOAuthCredential,
34
+ storePat,
35
+ tokenResponseToCredential,
36
+ unsetConfigValue,
37
+ unsetOrgScopedValue
38
+ } from "./chunk-XVXMDWQE.js";
2
39
 
3
40
  // src/index.ts
4
- import { Command as Command15 } from "commander";
41
+ import { Command as Command17 } from "commander";
5
42
 
6
43
  // src/version.ts
7
44
  import { readFileSync } from "fs";
@@ -14,6 +51,78 @@ var version = pkg.version;
14
51
  // src/commands/get-item.ts
15
52
  import { Command } from "commander";
16
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
+
17
126
  // src/services/azdo-client.ts
18
127
  var DEFAULT_FIELDS = [
19
128
  "System.Title",
@@ -26,16 +135,59 @@ var DEFAULT_FIELDS = [
26
135
  "System.AreaPath",
27
136
  "System.IterationPath"
28
137
  ];
29
- function authHeaders(pat) {
30
- const token = Buffer.from(`:${pat}`).toString("base64");
138
+ function authHeaders(credentialOrPat) {
139
+ if (typeof credentialOrPat === "string") {
140
+ const token2 = Buffer.from(`:${credentialOrPat}`).toString("base64");
141
+ return { Authorization: `Basic ${token2}` };
142
+ }
143
+ if (credentialOrPat.kind === "oauth") {
144
+ return { Authorization: `Bearer ${credentialOrPat.pat}` };
145
+ }
146
+ const token = Buffer.from(`:${credentialOrPat.pat}`).toString("base64");
31
147
  return { Authorization: `Basic ${token}` };
32
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
+ }
33
159
  async function fetchWithErrors(url, init) {
160
+ const writer = getActiveTraceWriter();
34
161
  let response;
35
162
  try {
36
163
  response = await fetch(url, init);
37
- } catch {
38
- 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);
39
191
  }
40
192
  if (response.status === 401) throw new Error("AUTH_FAILED");
41
193
  if (response.status === 403) throw new Error("PERMISSION_DENIED");
@@ -48,6 +200,10 @@ async function fetchWithErrors(url, init) {
48
200
  }
49
201
  throw new Error(`NOT_FOUND${detail}`);
50
202
  }
203
+ const contentType = response.headers?.get("content-type") ?? "";
204
+ if (contentType.toLowerCase().startsWith("text/html")) {
205
+ throw new Error("AUTH_FAILED");
206
+ }
51
207
  return response;
52
208
  }
53
209
  async function readResponseMessage(response) {
@@ -90,9 +246,9 @@ function buildExtraFields(fields, requested) {
90
246
  }
91
247
  return Object.keys(result).length > 0 ? result : null;
92
248
  }
93
- function writeHeaders(pat) {
249
+ function writeHeaders(cred) {
94
250
  return {
95
- ...authHeaders(pat),
251
+ ...authHeaders(cred),
96
252
  "Content-Type": "application/json-patch+json"
97
253
  };
98
254
  }
@@ -147,13 +303,13 @@ async function readWriteResponse(response, errorCode) {
147
303
  fields: data.fields
148
304
  };
149
305
  }
150
- async function getWorkItemFields(context, id, pat) {
306
+ async function getWorkItemFields(context, id, cred) {
151
307
  const url = new URL(
152
308
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
153
309
  );
154
310
  url.searchParams.set("api-version", "7.1");
155
311
  url.searchParams.set("$expand", "all");
156
- const response = await fetchWithErrors(url.toString(), { headers: authHeaders(pat) });
312
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
157
313
  if (response.status === 400) {
158
314
  const serverMessage = await readResponseMessage(response);
159
315
  if (serverMessage) {
@@ -188,10 +344,10 @@ function buildWorkItemUrl(context, id, options = {}) {
188
344
  }
189
345
  return url;
190
346
  }
191
- async function fetchWorkItemResponse(context, id, pat, options = {}) {
347
+ async function fetchWorkItemResponse(context, id, cred, options = {}) {
192
348
  const response = await fetchWithErrors(
193
349
  buildWorkItemUrl(context, id, options).toString(),
194
- { headers: authHeaders(pat) }
350
+ { headers: authHeaders(cred) }
195
351
  );
196
352
  if (response.status === 400) {
197
353
  const serverMessage = await readResponseMessage(response);
@@ -204,28 +360,66 @@ async function fetchWorkItemResponse(context, id, pat, options = {}) {
204
360
  }
205
361
  return await response.json();
206
362
  }
207
- async function getWorkItem(context, id, pat, extraFields) {
363
+ async function getOrgFieldNames(context, cred) {
364
+ const url = new URL(
365
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/_apis/wit/fields`
366
+ );
367
+ url.searchParams.set("api-version", "7.1");
368
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
369
+ if (!response.ok) {
370
+ throw new Error(`HTTP_${response.status}`);
371
+ }
372
+ const data = await response.json();
373
+ return (data.value ?? []).map((f) => f.referenceName);
374
+ }
375
+ function buildCombinedDescription(fields) {
376
+ const parts = [];
377
+ if (fields["System.Description"]) {
378
+ parts.push({ label: "Description", value: fields["System.Description"] });
379
+ }
380
+ if (fields["Microsoft.VSTS.Common.AcceptanceCriteria"]) {
381
+ parts.push({ label: "Acceptance Criteria", value: fields["Microsoft.VSTS.Common.AcceptanceCriteria"] });
382
+ }
383
+ if (fields["Microsoft.VSTS.TCM.ReproSteps"]) {
384
+ parts.push({ label: "Repro Steps", value: fields["Microsoft.VSTS.TCM.ReproSteps"] });
385
+ }
386
+ if (parts.length === 0) return null;
387
+ if (parts.length === 1) return parts[0].value;
388
+ return parts.map((p) => `<h3>${p.label}</h3>${p.value}`).join("");
389
+ }
390
+ async function fetchWorkItemWithFallback(context, id, cred, normalizedExtraFields) {
391
+ try {
392
+ const data = await fetchWorkItemResponse(context, id, cred, {
393
+ fields: normalizeFieldList([...DEFAULT_FIELDS, ...normalizedExtraFields])
394
+ });
395
+ return { data, effectiveExtraFields: normalizedExtraFields };
396
+ } catch (err) {
397
+ const msg = err instanceof Error ? err.message : String(err);
398
+ if (!msg.includes("TF51535")) throw err;
399
+ const orgFieldNames = await getOrgFieldNames(context, cred);
400
+ const orgFieldsLower = new Set(orgFieldNames.map((n) => n.toLowerCase()));
401
+ const missing = normalizedExtraFields.filter((f) => !orgFieldsLower.has(f.toLowerCase()));
402
+ const effectiveExtraFields = normalizedExtraFields.filter((f) => orgFieldsLower.has(f.toLowerCase()));
403
+ for (const f of missing) {
404
+ process.stderr.write(`azdo: warning: field '${f}' does not exist in organization '${context.org}' and was skipped
405
+ `);
406
+ }
407
+ const data = await fetchWorkItemResponse(context, id, cred, {
408
+ fields: normalizeFieldList([...DEFAULT_FIELDS, ...effectiveExtraFields])
409
+ });
410
+ return { data, effectiveExtraFields };
411
+ }
412
+ }
413
+ async function getWorkItem(context, id, cred, extraFields) {
208
414
  const normalizedExtraFields = extraFields ? normalizeFieldList(extraFields) : [];
209
- const data = normalizedExtraFields.length > 0 ? await fetchWorkItemResponse(context, id, pat, {
210
- fields: normalizeFieldList([...DEFAULT_FIELDS, ...normalizedExtraFields])
211
- }) : await fetchWorkItemResponse(context, id, pat, { includeRelations: true });
212
- const relationsData = normalizedExtraFields.length > 0 ? await fetchWorkItemResponse(context, id, pat, { includeRelations: true }) : data;
213
- const descriptionParts = [];
214
- if (data.fields["System.Description"]) {
215
- descriptionParts.push({ label: "Description", value: data.fields["System.Description"] });
216
- }
217
- if (data.fields["Microsoft.VSTS.Common.AcceptanceCriteria"]) {
218
- descriptionParts.push({ label: "Acceptance Criteria", value: data.fields["Microsoft.VSTS.Common.AcceptanceCriteria"] });
219
- }
220
- if (data.fields["Microsoft.VSTS.TCM.ReproSteps"]) {
221
- descriptionParts.push({ label: "Repro Steps", value: data.fields["Microsoft.VSTS.TCM.ReproSteps"] });
222
- }
223
- let combinedDescription = null;
224
- if (descriptionParts.length === 1) {
225
- combinedDescription = descriptionParts.at(0)?.value ?? null;
226
- } else if (descriptionParts.length > 1) {
227
- combinedDescription = descriptionParts.map((p) => `<h3>${p.label}</h3>${p.value}`).join("");
415
+ let effectiveExtraFields = normalizedExtraFields;
416
+ let data;
417
+ if (normalizedExtraFields.length > 0) {
418
+ ({ data, effectiveExtraFields } = await fetchWorkItemWithFallback(context, id, cred, normalizedExtraFields));
419
+ } else {
420
+ data = await fetchWorkItemResponse(context, id, cred, { includeRelations: true });
228
421
  }
422
+ const relationsData = normalizedExtraFields.length > 0 ? await fetchWorkItemResponse(context, id, cred, { includeRelations: true }) : data;
229
423
  return {
230
424
  id: data.id,
231
425
  rev: data.rev,
@@ -233,21 +427,21 @@ async function getWorkItem(context, id, pat, extraFields) {
233
427
  state: data.fields["System.State"],
234
428
  type: data.fields["System.WorkItemType"],
235
429
  assignedTo: data.fields["System.AssignedTo"]?.displayName ?? null,
236
- description: combinedDescription,
430
+ description: buildCombinedDescription(data.fields),
237
431
  areaPath: data.fields["System.AreaPath"],
238
432
  iterationPath: data.fields["System.IterationPath"],
239
433
  url: data._links.html.href,
240
- extraFields: normalizedExtraFields.length > 0 ? buildExtraFields(data.fields, normalizedExtraFields) : null,
434
+ extraFields: effectiveExtraFields.length > 0 ? buildExtraFields(data.fields, effectiveExtraFields) : null,
241
435
  attachments: extractAttachments(relationsData.relations)
242
436
  };
243
437
  }
244
- async function getWorkItemFieldValue(context, id, pat, fieldName) {
438
+ async function getWorkItemFieldValue(context, id, cred, fieldName) {
245
439
  const url = new URL(
246
440
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
247
441
  );
248
442
  url.searchParams.set("api-version", "7.1");
249
443
  url.searchParams.set("fields", fieldName);
250
- const response = await fetchWithErrors(url.toString(), { headers: authHeaders(pat) });
444
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
251
445
  if (response.status === 400) {
252
446
  const serverMessage = await readResponseMessage(response);
253
447
  if (serverMessage) {
@@ -264,13 +458,13 @@ async function getWorkItemFieldValue(context, id, pat, fieldName) {
264
458
  }
265
459
  return stringifyFieldValue(value);
266
460
  }
267
- async function listWorkItemComments(context, id, pat) {
461
+ async function listWorkItemComments(context, id, cred) {
268
462
  const comments = [];
269
463
  let continuationToken = null;
270
464
  do {
271
465
  const response = await fetchWithErrors(
272
466
  buildWorkItemCommentsListUrl(context, id, continuationToken ?? void 0).toString(),
273
- { headers: authHeaders(pat) }
467
+ { headers: authHeaders(cred) }
274
468
  );
275
469
  if (!response.ok) {
276
470
  throw new Error(`HTTP_${response.status}`);
@@ -287,13 +481,13 @@ async function listWorkItemComments(context, id, pat) {
287
481
  comments
288
482
  };
289
483
  }
290
- async function addWorkItemComment(context, id, pat, text, format = "html") {
484
+ async function addWorkItemComment(context, id, cred, text, format = "html") {
291
485
  const url = buildWorkItemCommentsUrl(context, id);
292
486
  url.searchParams.set("format", format);
293
487
  const response = await fetchWithErrors(url.toString(), {
294
488
  method: "POST",
295
489
  headers: {
296
- ...authHeaders(pat),
490
+ ...authHeaders(cred),
297
491
  "Content-Type": "application/json"
298
492
  },
299
493
  body: JSON.stringify({ text })
@@ -315,8 +509,8 @@ async function addWorkItemComment(context, id, pat, text, format = "html") {
315
509
  url: data.url ?? null
316
510
  };
317
511
  }
318
- async function updateWorkItem(context, id, pat, fieldName, operations) {
319
- const result = await applyWorkItemPatch(context, id, pat, operations);
512
+ async function updateWorkItem(context, id, cred, fieldName, operations) {
513
+ const result = await applyWorkItemPatch(context, id, cred, operations);
320
514
  const title = result.fields["System.Title"];
321
515
  const lastOp = operations.at(-1);
322
516
  const fieldValue = lastOp?.value ?? null;
@@ -328,32 +522,32 @@ async function updateWorkItem(context, id, pat, fieldName, operations) {
328
522
  fieldValue
329
523
  };
330
524
  }
331
- async function createWorkItem(context, workItemType, pat, operations) {
525
+ async function createWorkItem(context, workItemType, cred, operations) {
332
526
  const url = new URL(
333
527
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/$${encodeURIComponent(workItemType)}`
334
528
  );
335
529
  url.searchParams.set("api-version", "7.1");
336
530
  const response = await fetchWithErrors(url.toString(), {
337
531
  method: "POST",
338
- headers: writeHeaders(pat),
532
+ headers: writeHeaders(cred),
339
533
  body: JSON.stringify(operations)
340
534
  });
341
535
  return readWriteResponse(response, "CREATE_REJECTED");
342
536
  }
343
- async function applyWorkItemPatch(context, id, pat, operations) {
537
+ async function applyWorkItemPatch(context, id, cred, operations) {
344
538
  const url = new URL(
345
539
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
346
540
  );
347
541
  url.searchParams.set("api-version", "7.1");
348
542
  const response = await fetchWithErrors(url.toString(), {
349
543
  method: "PATCH",
350
- headers: writeHeaders(pat),
544
+ headers: writeHeaders(cred),
351
545
  body: JSON.stringify(operations)
352
546
  });
353
547
  return readWriteResponse(response, "UPDATE_REJECTED");
354
548
  }
355
- async function downloadAttachment(url, pat) {
356
- const response = await fetchWithErrors(url, { headers: authHeaders(pat) });
549
+ async function downloadAttachment(url, cred) {
550
+ const response = await fetchWithErrors(url, { headers: authHeaders(cred) });
357
551
  if (!response.ok) {
358
552
  throw new Error(`HTTP_${response.status}`);
359
553
  }
@@ -365,323 +559,124 @@ import { createInterface } from "readline";
365
559
  import { existsSync, readFileSync as readFileSync2 } from "fs";
366
560
  import { dirname as dirname2, join } from "path";
367
561
 
368
- // src/services/credential-store.ts
369
- import { Entry } from "@napi-rs/keyring";
370
-
371
- // src/types/credential.ts
372
- var CredentialStoreUnavailableError = class extends Error {
373
- backend;
374
- constructor(backend, cause) {
375
- super(`OS secret backend unavailable (${backend}). Install the platform's credential service and try again.`);
376
- this.name = "CredentialStoreUnavailableError";
377
- this.backend = backend;
562
+ // src/services/oauth-device-code.ts
563
+ var DeviceCodeFlowError = class extends Error {
564
+ reason;
565
+ constructor(reason, message, cause) {
566
+ super(message);
567
+ this.name = "DeviceCodeFlowError";
568
+ this.reason = reason;
378
569
  if (cause instanceof Error) {
379
570
  this.cause = cause;
380
571
  }
381
572
  }
382
573
  };
383
-
384
- // src/services/audit-log.ts
385
- import fs from "fs";
386
- import os from "os";
387
- import path from "path";
388
- function getAuditLogPath() {
389
- return path.join(os.homedir(), ".azdo", "audit.log");
390
- }
391
- function ensureDirWithPerms(dir) {
392
- if (!fs.existsSync(dir)) {
393
- fs.mkdirSync(dir, { recursive: true, mode: 448 });
394
- return;
395
- }
574
+ var MIN_INTERVAL_SEC = 5;
575
+ async function defaultSleep(ms) {
576
+ await new Promise((r) => setTimeout(r, ms));
577
+ }
578
+ async function requestDeviceCode(oauthConfig, fetchFn) {
579
+ const body = new URLSearchParams({
580
+ client_id: oauthConfig.clientId,
581
+ scope: buildScopeString(oauthConfig.scopes)
582
+ });
583
+ const response = await fetchFn(oauthConfig.deviceCodeEndpoint, {
584
+ method: "POST",
585
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
586
+ body: body.toString()
587
+ });
588
+ const text = await response.text();
589
+ let parsed;
396
590
  try {
397
- fs.chmodSync(dir, 448);
591
+ parsed = JSON.parse(text);
398
592
  } catch {
593
+ throw new DeviceCodeFlowError("idp-error", `device-code endpoint returned non-JSON HTTP ${response.status}: ${text.slice(0, 200)}`);
399
594
  }
595
+ if (!response.ok) {
596
+ const err = parsed;
597
+ const code = err.error ?? "unknown";
598
+ const desc = err.error_description ? `: ${err.error_description}` : "";
599
+ throw new DeviceCodeFlowError(
600
+ "idp-error",
601
+ `device-code endpoint rejected request (${response.status}): ${code}${desc}`
602
+ );
603
+ }
604
+ return parsed;
400
605
  }
401
- function ensureFileWithPerms(file) {
402
- if (!fs.existsSync(file)) {
403
- fs.writeFileSync(file, "", { mode: 384 });
404
- return;
606
+ async function classifyDeviceTokenResponse(response) {
607
+ if (response.ok) {
608
+ return { kind: "success", token: await readTokenResponse(response) };
405
609
  }
610
+ const text = await response.text();
611
+ let parsed;
406
612
  try {
407
- fs.chmodSync(file, 384);
613
+ parsed = JSON.parse(text);
408
614
  } catch {
615
+ throw new DeviceCodeFlowError("idp-error", `non-JSON HTTP ${response.status}: ${text.slice(0, 200)}`);
409
616
  }
410
- }
411
- function appendAuthAuditEvent(input) {
412
- const auditLog = getAuditLogPath();
413
- const dir = path.dirname(auditLog);
414
- ensureDirWithPerms(dir);
415
- ensureFileWithPerms(auditLog);
416
- const record = {
417
- ts: (/* @__PURE__ */ new Date()).toISOString(),
418
- event: input.event,
419
- org: input.org,
420
- backend: input.backend,
421
- ...input.masked_pat !== void 0 ? { masked_pat: input.masked_pat } : {}
422
- };
423
- fs.appendFileSync(auditLog, `${JSON.stringify(record)}
424
- `);
425
- }
426
- function readAuditEvents() {
427
- const auditLog = getAuditLogPath();
428
- if (!fs.existsSync(auditLog)) {
429
- return [];
430
- }
431
- const contents = fs.readFileSync(auditLog, "utf8");
432
- const out = [];
433
- for (const line of contents.split("\n")) {
434
- const trimmed = line.trim();
435
- if (!trimmed) continue;
436
- try {
437
- const parsed = JSON.parse(trimmed);
438
- if (parsed && typeof parsed === "object" && typeof parsed.event === "string") {
439
- out.push(parsed);
440
- }
441
- } catch {
442
- }
617
+ const errCode = parsed.error ?? "";
618
+ if (errCode === "authorization_pending") return { kind: "pending" };
619
+ if (errCode === "slow_down") return { kind: "slow_down" };
620
+ if (errCode === "expired_token") {
621
+ throw new DeviceCodeFlowError("expired_token", "device code expired before authorisation completed");
443
622
  }
444
- return out;
445
- }
446
-
447
- // src/services/config-store.ts
448
- import fs2 from "fs";
449
- import path2 from "path";
450
- import os2 from "os";
451
- var SETTINGS = [
452
- {
453
- key: "org",
454
- description: "Azure DevOps organization name",
455
- type: "string",
456
- example: "mycompany",
457
- required: true
458
- },
459
- {
460
- key: "project",
461
- description: "Azure DevOps project name",
462
- type: "string",
463
- example: "MyProject",
464
- required: true
465
- },
466
- {
467
- key: "fields",
468
- description: "Extra work item fields to include (comma-separated reference names)",
469
- type: "string[]",
470
- example: "System.Tags,Custom.Priority",
471
- required: false
472
- },
473
- {
474
- key: "markdown",
475
- description: "Convert rich text fields to markdown on display",
476
- type: "boolean",
477
- example: "true",
478
- required: false
623
+ if (errCode === "access_denied") {
624
+ throw new DeviceCodeFlowError("access_denied", "authorisation denied by user");
479
625
  }
480
- ];
481
- var VALID_KEYS = SETTINGS.map((s) => s.key);
482
- function getConfigPath() {
483
- return path2.join(os2.homedir(), ".azdo", "config.json");
626
+ const desc = parsed.error_description ? `: ${parsed.error_description}` : "";
627
+ throw new DeviceCodeFlowError(
628
+ "idp-error",
629
+ `IdP rejected device-token poll (${response.status}): ${errCode}${desc}`
630
+ );
484
631
  }
485
- function loadConfig() {
486
- const configPath = getConfigPath();
487
- let raw;
488
- try {
489
- raw = fs2.readFileSync(configPath, "utf-8");
490
- } catch (err) {
491
- if (err.code === "ENOENT") {
492
- return {};
632
+ async function pollForDeviceToken(deviceCode, oauthConfig, initialIntervalSec, expiresAtMs, deps) {
633
+ const fetchFn = deps.fetch ?? fetch;
634
+ const now = deps.now ?? (() => Date.now());
635
+ const sleep2 = deps.sleep ?? defaultSleep;
636
+ let intervalSec = Math.max(MIN_INTERVAL_SEC, initialIntervalSec);
637
+ for (; ; ) {
638
+ if (now() >= expiresAtMs) {
639
+ throw new DeviceCodeFlowError("expired_token", "device-code flow expired before authorisation completed");
493
640
  }
494
- throw err;
495
- }
496
- try {
497
- return JSON.parse(raw);
498
- } catch {
499
- process.stderr.write(`Warning: Config file ${configPath} contains invalid JSON. Using defaults.
500
- `);
501
- return {};
502
- }
503
- }
504
- function saveConfig(config) {
505
- const configPath = getConfigPath();
506
- const dir = path2.dirname(configPath);
507
- fs2.mkdirSync(dir, { recursive: true });
508
- fs2.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
509
- }
510
- function validateKey(key) {
511
- if (!VALID_KEYS.includes(key)) {
512
- throw new Error(`Unknown setting key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
513
- }
514
- }
515
- function getConfigValue(key) {
516
- validateKey(key);
517
- const config = loadConfig();
518
- return config[key];
519
- }
520
- function setConfigValue(key, value) {
521
- validateKey(key);
522
- const config = loadConfig();
523
- if (value === "") {
524
- delete config[key];
525
- } else if (key === "markdown") {
526
- if (value !== "true" && value !== "false") {
527
- throw new Error(`Invalid value "${value}" for markdown. Must be "true" or "false".`);
641
+ await sleep2(intervalSec * 1e3);
642
+ const body = new URLSearchParams({
643
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
644
+ client_id: oauthConfig.clientId,
645
+ device_code: deviceCode
646
+ });
647
+ const response = await fetchFn(oauthConfig.tokenEndpoint, {
648
+ method: "POST",
649
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
650
+ body: body.toString()
651
+ });
652
+ const outcome = await classifyDeviceTokenResponse(response);
653
+ if (outcome.kind === "success") return outcome.token;
654
+ if (outcome.kind === "slow_down") {
655
+ intervalSec += 5;
528
656
  }
529
- config.markdown = value === "true";
530
- } else if (key === "fields") {
531
- config.fields = value.split(",").map((s) => s.trim());
532
- } else {
533
- config[key] = value;
534
657
  }
535
- saveConfig(config);
536
- }
537
- function unsetConfigValue(key) {
538
- validateKey(key);
539
- const config = loadConfig();
540
- delete config[key];
541
- saveConfig(config);
542
658
  }
659
+ async function runDeviceCodeFlow(org, oauthConfig, deps = {}) {
660
+ const fetchFn = deps.fetch ?? fetch;
661
+ const now = deps.now ?? (() => Date.now());
662
+ const writePrompt = deps.writePrompt ?? ((m) => {
663
+ process.stderr.write(m);
664
+ });
665
+ const dc = await requestDeviceCode(oauthConfig, fetchFn);
666
+ const expiryMin = Math.round(dc.expires_in / 60);
667
+ writePrompt(
668
+ `
669
+ To authenticate, open ${dc.verification_uri} in a browser and enter the code:
543
670
 
544
- // src/services/auth-masking.ts
545
- var VISIBLE_CHARS = 5;
546
- function maskedDisplay(pat) {
547
- if (pat.length <= VISIBLE_CHARS * 2) {
548
- return pat;
549
- }
550
- const hiddenCount = pat.length - VISIBLE_CHARS * 2;
551
- return pat.slice(0, VISIBLE_CHARS) + "*".repeat(hiddenCount) + pat.slice(-VISIBLE_CHARS);
552
- }
553
- function normalizePat(rawPat) {
554
- const trimmedPat = rawPat.trim();
555
- return trimmedPat.length > 0 ? trimmedPat : null;
556
- }
671
+ ${dc.user_code}
557
672
 
558
- // src/services/credential-store.ts
559
- var SERVICE = "azdo-cli";
560
- var LEGACY_ACCOUNT = "pat";
561
- function accountFor(org) {
562
- return `pat:${org}`;
563
- }
564
- function probeBackend() {
565
- switch (process.platform) {
566
- case "win32":
567
- return "windows-credential-manager";
568
- case "darwin":
569
- return "macos-keychain";
570
- case "linux":
571
- return "linux-libsecret";
572
- default:
573
- return "unknown";
574
- }
575
- }
576
- function wrapUnavailable(fn) {
577
- try {
578
- return fn();
579
- } catch (err) {
580
- throw new CredentialStoreUnavailableError(probeBackend(), err);
581
- }
582
- }
583
- var legacyUnsetNoticeEmitted = false;
584
- function emitLegacyUnsetNoticeOnce() {
585
- if (legacyUnsetNoticeEmitted) return;
586
- legacyUnsetNoticeEmitted = true;
587
- process.stderr.write(
588
- 'A legacy PAT exists in the OS vault from a previous azdo-cli version, but no "org" is set in config. Run `azdo auth --org <name>` to re-store it under the per-org key, then `azdo clear-pat` to remove the legacy slot.\n'
673
+ Waiting for authorisation (expires in ${expiryMin} min)\u2026
674
+ `
589
675
  );
590
- }
591
- async function maybeMigrateLegacy(targetOrg) {
592
- const config = loadConfig();
593
- if (!config.org || config.org !== targetOrg) {
594
- if (!config.org) {
595
- let legacyExists;
596
- try {
597
- const legacyEntry2 = new Entry(SERVICE, LEGACY_ACCOUNT);
598
- legacyExists = legacyEntry2.getPassword() !== null;
599
- } catch {
600
- legacyExists = false;
601
- }
602
- if (legacyExists) {
603
- emitLegacyUnsetNoticeOnce();
604
- }
605
- }
606
- return null;
607
- }
608
- const newEntry = new Entry(SERVICE, accountFor(targetOrg));
609
- const existingNew = wrapUnavailable(() => newEntry.getPassword());
610
- if (existingNew !== null) {
611
- return null;
612
- }
613
- const legacyEntry = new Entry(SERVICE, LEGACY_ACCOUNT);
614
- const legacy = wrapUnavailable(() => legacyEntry.getPassword());
615
- if (legacy === null) {
616
- return null;
617
- }
618
- wrapUnavailable(() => {
619
- newEntry.setPassword(legacy);
620
- legacyEntry.deletePassword();
621
- });
622
- appendAuthAuditEvent({
623
- event: "auth.store",
624
- org: targetOrg,
625
- backend: probeBackend(),
626
- masked_pat: maskedDisplay(legacy)
627
- });
628
- process.stderr.write(`Migrated legacy PAT to org ${targetOrg}.
629
- `);
630
- return legacy;
631
- }
632
- async function getPat(org) {
633
- const entry = new Entry(SERVICE, accountFor(org));
634
- const value = wrapUnavailable(() => entry.getPassword());
635
- if (value !== null) {
636
- return value;
637
- }
638
- const migrated = await maybeMigrateLegacy(org);
639
- return migrated;
640
- }
641
- async function storePat(org, pat) {
642
- const entry = new Entry(SERVICE, accountFor(org));
643
- wrapUnavailable(() => entry.setPassword(pat));
644
- appendAuthAuditEvent({
645
- event: "auth.store",
646
- org,
647
- backend: probeBackend(),
648
- masked_pat: maskedDisplay(pat)
649
- });
650
- }
651
- async function deletePat(org) {
652
- const entry = new Entry(SERVICE, accountFor(org));
653
- const existing = wrapUnavailable(() => entry.getPassword());
654
- if (existing === null) {
655
- return false;
656
- }
657
- wrapUnavailable(() => entry.deletePassword());
658
- appendAuthAuditEvent({
659
- event: "auth.delete",
660
- org,
661
- backend: probeBackend(),
662
- masked_pat: maskedDisplay(existing)
663
- });
664
- return true;
665
- }
666
- async function listOrgsWithStoredPat() {
667
- const seen = /* @__PURE__ */ new Set();
668
- for (const ev of readAuditEvents()) {
669
- if (ev.event === "auth.store") {
670
- seen.add(ev.org);
671
- } else if (ev.event === "auth.delete") {
672
- seen.delete(ev.org);
673
- }
674
- }
675
- const present = [];
676
- for (const org of seen) {
677
- const entry = new Entry(SERVICE, accountFor(org));
678
- const value = wrapUnavailable(() => entry.getPassword());
679
- if (value !== null) {
680
- present.push(org);
681
- }
682
- }
683
- present.sort((a, b) => a.localeCompare(b));
684
- return present;
676
+ const expiresAtMs = now() + dc.expires_in * 1e3;
677
+ const token = await pollForDeviceToken(dc.device_code, oauthConfig, dc.interval, expiresAtMs, deps);
678
+ const credential = tokenResponseToCredential(org, oauthConfig, token, now());
679
+ return { credential, flowUsed: "device-code" };
685
680
  }
686
681
 
687
682
  // src/services/auth.ts
@@ -737,7 +732,7 @@ function findDotEnvPat(startDir = process.cwd()) {
737
732
  if (existsSync(envFile)) {
738
733
  const contents = readFileSync2(envFile, "utf8");
739
734
  for (const line of contents.split("\n")) {
740
- const match = line.match(/^AZDO_PAT\s*=\s*(.+)$/);
735
+ const match = line.match(/^AZDO_PAT\s*=([^\n\r]+)$/);
741
736
  if (match) {
742
737
  const value = match[1].trim().replace(/^["']|["']$/g, "");
743
738
  if (value.length > 0) return value;
@@ -750,29 +745,36 @@ function findDotEnvPat(startDir = process.cwd()) {
750
745
  }
751
746
  return null;
752
747
  }
753
- async function resolvePat(org) {
748
+ async function resolveAuthCredential(org) {
754
749
  const envPat = process.env.AZDO_PAT;
755
750
  if (envPat && envPat.length > 0) {
756
- return { pat: envPat, source: "env" };
751
+ return { pat: envPat, source: "env", kind: "pat" };
757
752
  }
758
- const storedPat = await getPat(org);
759
- if (storedPat !== null) {
760
- return { pat: storedPat, source: "credential-store" };
753
+ const stored = await getStoredCredential(org);
754
+ if (stored !== null) {
755
+ if (stored.kind === "pat") {
756
+ return { pat: stored.token, source: "credential-store", kind: "pat" };
757
+ }
758
+ const fresh = await refreshIfNeeded(org, stored);
759
+ return {
760
+ pat: fresh.accessToken,
761
+ source: "credential-store",
762
+ kind: "oauth",
763
+ accountId: fresh.accountId
764
+ };
761
765
  }
762
766
  const dotEnvPat = findDotEnvPat();
763
767
  if (dotEnvPat !== null) {
764
- return { pat: dotEnvPat, source: "env" };
768
+ return { pat: dotEnvPat, source: "env", kind: "pat" };
765
769
  }
766
770
  return null;
767
771
  }
768
- async function requirePat(org) {
769
- const cred = await resolvePat(org);
772
+ async function requireAuthCredential(org) {
773
+ const cred = await resolveAuthCredential(org);
770
774
  if (cred !== null) {
771
775
  return cred;
772
776
  }
773
- throw new Error(
774
- `No PAT available for org "${org}". Set AZDO_PAT environment variable or run \`azdo auth --org ${org}\`.`
775
- );
777
+ throw new CredentialMissingError(org);
776
778
  }
777
779
  async function validatePatAgainstAzdo(pat, org) {
778
780
  const url = `https://dev.azure.com/${encodeURIComponent(org)}/_apis/projects?$top=1&api-version=7.1`;
@@ -791,51 +793,309 @@ async function validatePatAgainstAzdo(pat, org) {
791
793
  }
792
794
  throw new Error(`Azure DevOps returned HTTP ${response.status} while validating PAT for org "${org}".`);
793
795
  }
796
+ var LOGIN_FAILURE_REASONS = /* @__PURE__ */ new Set([
797
+ "user-cancelled",
798
+ "port-conflict",
799
+ "state-mismatch",
800
+ "redirect-mismatch",
801
+ "idp-error",
802
+ "timeout",
803
+ "expired_token",
804
+ "access_denied"
805
+ ]);
806
+ function extractLoginFailureReason(err) {
807
+ if (typeof err === "object" && err !== null && "reason" in err) {
808
+ const r = err.reason;
809
+ if (typeof r === "string" && LOGIN_FAILURE_REASONS.has(r)) {
810
+ return r;
811
+ }
812
+ }
813
+ return "unknown";
814
+ }
815
+ async function loginWithOAuth(org, opts = {}) {
816
+ const oauthConfig = resolveOAuthConfig({
817
+ clientIdOverride: opts.clientIdOverride,
818
+ tenantIdOverride: opts.tenantIdOverride,
819
+ scopesOverride: opts.scopesOverride
820
+ });
821
+ const isHeadlessRuntime = () => {
822
+ if (opts.forceHeadless) return true;
823
+ if (process.platform === "linux") {
824
+ return !process.env.DISPLAY || process.env.DISPLAY.length === 0;
825
+ }
826
+ return false;
827
+ };
828
+ const useDeviceCode = opts.flow === "device-code" || opts.flow !== "auth-code" && isHeadlessRuntime();
829
+ appendAuthAuditEvent({
830
+ event: "oauth-login-started",
831
+ org,
832
+ backend: probeBackend(),
833
+ flow: useDeviceCode ? "device-code" : "auth-code",
834
+ clientIdSource: oauthConfig.clientIdSource
835
+ });
836
+ let credential;
837
+ let flowUsed;
838
+ try {
839
+ if (useDeviceCode) {
840
+ const r = await runDeviceCodeFlow(org, oauthConfig);
841
+ credential = r.credential;
842
+ flowUsed = "device-code";
843
+ } else {
844
+ const r = await runAuthCodeFlow(org, oauthConfig);
845
+ credential = r.credential;
846
+ flowUsed = "auth-code";
847
+ }
848
+ } catch (err) {
849
+ appendAuthAuditEvent({
850
+ event: "oauth-login-failed",
851
+ org,
852
+ backend: probeBackend(),
853
+ flow: useDeviceCode ? "device-code" : "auth-code",
854
+ reason: extractLoginFailureReason(err)
855
+ });
856
+ throw err;
857
+ }
858
+ await storeOAuthCredential(org, credential);
859
+ appendAuthAuditEvent({
860
+ event: "oauth-login-success",
861
+ org,
862
+ backend: probeBackend(),
863
+ flow: flowUsed,
864
+ clientIdSource: oauthConfig.clientIdSource,
865
+ accountId: credential.accountId,
866
+ scope: credential.scope,
867
+ tokenLifetimeSec: credential.expiresAt - credential.issuedAt
868
+ });
869
+ return {
870
+ org,
871
+ kind: "oauth",
872
+ accountId: credential.accountId,
873
+ expiresAt: credential.expiresAt,
874
+ scope: credential.scope,
875
+ flowUsed
876
+ };
877
+ }
878
+ async function logout(opts = {}) {
879
+ if (opts.all) {
880
+ const orgs = await listOrgsWithStoredPat();
881
+ const removed = [];
882
+ for (const o of orgs) {
883
+ const cred2 = await getStoredCredential(o);
884
+ const ok2 = await deletePat(o);
885
+ if (ok2 && cred2 !== null) {
886
+ removed.push({ org: o, kind: cred2.kind });
887
+ }
888
+ }
889
+ return { removed };
890
+ }
891
+ if (!opts.org) {
892
+ throw new Error("logout requires an org or --all");
893
+ }
894
+ const cred = await getStoredCredential(opts.org);
895
+ const ok = await deletePat(opts.org);
896
+ return { removed: ok && cred !== null ? [{ org: opts.org, kind: cred.kind }] : [] };
897
+ }
898
+ async function status() {
899
+ const orgs = await listOrgsWithStoredPat();
900
+ const out = [];
901
+ for (const org of orgs) {
902
+ const cred = await getStoredCredential(org);
903
+ if (cred === null) continue;
904
+ if (cred.kind === "pat") {
905
+ out.push({ org, kind: "pat", backend: probeBackend() });
906
+ } else {
907
+ out.push({
908
+ org,
909
+ kind: "oauth",
910
+ accountId: cred.accountId,
911
+ expiresAt: cred.expiresAt,
912
+ scope: cred.scope,
913
+ backend: probeBackend()
914
+ });
915
+ }
916
+ }
917
+ return { orgs: out };
918
+ }
919
+
920
+ // src/services/git-remote.ts
921
+ import { execFileSync } from "child_process";
922
+ import fs from "fs";
923
+ import path from "path";
924
+
925
+ // src/services/remote-warning.ts
926
+ var warned = false;
927
+ function buildWarning(remoteName) {
928
+ return `azdo: warning: ${remoteName} includes embedded credentials; consider removing them with 'git remote set-url ${remoteName} <clean-url>'
929
+ `;
930
+ }
931
+ function noticeCredentialBearingRemote(remoteName = "origin") {
932
+ if (warned) {
933
+ return;
934
+ }
935
+ warned = true;
936
+ try {
937
+ process.stderr.write(buildWarning(remoteName));
938
+ } catch {
939
+ }
940
+ }
794
941
 
795
942
  // src/services/git-remote.ts
796
- import { execSync } from "child_process";
943
+ var GIT_BINARY = (() => {
944
+ const known = process.platform === "win32" ? [String.raw`C:\Program Files\Git\bin\git.exe`, String.raw`C:\Program Files (x86)\Git\bin\git.exe`] : ["/usr/bin/git", "/usr/local/bin/git", "/opt/homebrew/bin/git"];
945
+ return known.find((p) => {
946
+ try {
947
+ execFileSync(p, ["--version"], { stdio: "ignore" });
948
+ return true;
949
+ } catch {
950
+ return false;
951
+ }
952
+ }) ?? "git";
953
+ })();
797
954
  var patterns = [
798
- // HTTPS (current): https://dev.azure.com/{org}/{project}/_git/{repo}
799
- /^https?:\/\/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+)$/,
800
- // HTTPS (legacy + DefaultCollection): https://{org}.visualstudio.com/DefaultCollection/{project}/_git/{repo}
801
- /^https?:\/\/([^.]+)\.visualstudio\.com\/DefaultCollection\/([^/]+)\/_git\/([^/]+)$/,
802
- // HTTPS (legacy): https://{org}.visualstudio.com/{project}/_git/{repo}
803
- /^https?:\/\/([^.]+)\.visualstudio\.com\/([^/]+)\/_git\/([^/]+)$/,
804
- // SSH (current): git@ssh.dev.azure.com:v3/{org}/{project}/{repo}
805
- /^git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/([^/]+)$/,
806
- // SSH (legacy): {org}@vs-ssh.visualstudio.com:v3/{org}/{project}/{repo}
807
- /^[^@]+@vs-ssh\.visualstudio\.com:v3\/([^/]+)\/([^/]+)\/([^/]+)$/
955
+ // HTTPS (current): https://[user[:token]@]dev.azure.com/{org}/{project}/_git/{repo}[.git]
956
+ /^https?:\/\/(?:[^@/]+@)?dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+?)(?:\.git)?$/,
957
+ // HTTPS (legacy + DefaultCollection): https://[user[:token]@]{org}.visualstudio.com/DefaultCollection/{project}/_git/{repo}[.git]
958
+ /^https?:\/\/(?:[^@/]+@)?([^.]+)\.visualstudio\.com\/DefaultCollection\/([^/]+)\/_git\/([^/]+?)(?:\.git)?$/,
959
+ // HTTPS (legacy): https://[user[:token]@]{org}.visualstudio.com/{project}/_git/{repo}[.git]
960
+ /^https?:\/\/(?:[^@/]+@)?([^.]+)\.visualstudio\.com\/([^/]+)\/_git\/([^/]+?)(?:\.git)?$/,
961
+ // SSH (current): git@ssh.dev.azure.com:v3/{org}/{project}/{repo}[.git]
962
+ /^git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?$/,
963
+ // SSH (legacy): {org}@vs-ssh.visualstudio.com:v3/{org}/{project}/{repo}[.git]
964
+ /^[^@]+@vs-ssh\.visualstudio\.com:v3\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?$/
808
965
  ];
809
- function parseAzdoRemote(url) {
966
+ var httpsEmbeddedSecret = /^https?:\/\/[^:@/]+:[^@/]+@/;
967
+ function parseSingleRemoteLine(line) {
968
+ const trimmed = line.trim();
969
+ if (!trimmed) return null;
970
+ const tabIdx = trimmed.indexOf(" ");
971
+ if (tabIdx === -1) return null;
972
+ const remoteName = trimmed.slice(0, tabIdx);
973
+ const afterTab = trimmed.slice(tabIdx + 1);
974
+ const urlEnd = afterTab.lastIndexOf(" (");
975
+ const url = urlEnd === -1 ? afterTab : afterTab.slice(0, urlEnd);
976
+ return { remoteName, url };
977
+ }
978
+ function matchAzdoRemote(remoteName, url) {
810
979
  for (const pattern of patterns) {
811
980
  const match = pattern.exec(url);
812
- if (match) {
813
- const project = match[2];
814
- if (/^DefaultCollection$/i.test(project)) {
815
- return { org: match[1], project: "" };
981
+ if (!match) continue;
982
+ const project = match[2];
983
+ if (/^DefaultCollection$/i.test(project)) return null;
984
+ return { remoteName, org: match[1], project, hasEmbeddedSecret: httpsEmbeddedSecret.test(url) };
985
+ }
986
+ return null;
987
+ }
988
+ function parseAllAzdoRemotes(output) {
989
+ const seen = /* @__PURE__ */ new Set();
990
+ const results = [];
991
+ for (const line of output.split("\n")) {
992
+ const parsed = parseSingleRemoteLine(line);
993
+ if (!parsed) continue;
994
+ const { remoteName, url } = parsed;
995
+ if (seen.has(remoteName)) continue;
996
+ const candidate = matchAzdoRemote(remoteName, url);
997
+ if (candidate) {
998
+ seen.add(remoteName);
999
+ results.push(candidate);
1000
+ }
1001
+ }
1002
+ return results;
1003
+ }
1004
+ function selectRemote(candidates) {
1005
+ if (candidates.length === 0) {
1006
+ throw new Error("No Azure DevOps remote found. Provide --org and --project explicitly.");
1007
+ }
1008
+ const origin = candidates.find((c) => c.remoteName === "origin");
1009
+ if (origin) return origin;
1010
+ if (candidates.length === 1) return candidates[0];
1011
+ const first = candidates[0];
1012
+ const allSame = candidates.every(
1013
+ (c) => c.org.toLowerCase() === first.org.toLowerCase() && c.project.toLowerCase() === first.project.toLowerCase()
1014
+ );
1015
+ if (allSame) return first;
1016
+ const lines = candidates.map((c) => ` ${c.remoteName.padEnd(8)} \u2192 ${c.org}/${c.project}`).join("\n");
1017
+ throw new Error(
1018
+ `Multiple Azure DevOps remotes found with different org/project:
1019
+ ${lines}
1020
+ Use --org/--project (or 'git remote rename <name> origin') to disambiguate.`
1021
+ );
1022
+ }
1023
+ function readGitConfigContent() {
1024
+ const gitDirEnv = process.env.GIT_DIR;
1025
+ if (gitDirEnv) {
1026
+ return fs.readFileSync(path.join(gitDirEnv, "config"), "utf-8");
1027
+ }
1028
+ let dir = process.cwd();
1029
+ for (; ; ) {
1030
+ const gitPath = path.join(dir, ".git");
1031
+ try {
1032
+ const stat = fs.statSync(gitPath);
1033
+ if (stat.isDirectory()) {
1034
+ return fs.readFileSync(path.join(gitPath, "config"), "utf-8");
1035
+ }
1036
+ if (stat.isFile()) {
1037
+ const ref = fs.readFileSync(gitPath, "utf-8");
1038
+ const m = /^gitdir:[ \t]*([^\r\n]+)/m.exec(ref);
1039
+ if (m) {
1040
+ return fs.readFileSync(path.join(path.resolve(dir, m[1].trim()), "config"), "utf-8");
1041
+ }
1042
+ }
1043
+ } catch {
1044
+ }
1045
+ const parent = path.dirname(dir);
1046
+ if (parent === dir) break;
1047
+ dir = parent;
1048
+ }
1049
+ throw new Error("Not in a git repository. Provide --org and --project explicitly.");
1050
+ }
1051
+ function gitConfigToRemoteLines(configContent) {
1052
+ const lines = [];
1053
+ let currentRemote = null;
1054
+ let emittedUrl = false;
1055
+ for (const line of configContent.split("\n")) {
1056
+ const sectionMatch = /^\[remote\s+"([^"]+)"\]/.exec(line);
1057
+ if (sectionMatch) {
1058
+ currentRemote = sectionMatch[1];
1059
+ emittedUrl = false;
1060
+ continue;
1061
+ }
1062
+ if (line.startsWith("[")) {
1063
+ currentRemote = null;
1064
+ emittedUrl = false;
1065
+ continue;
1066
+ }
1067
+ if (currentRemote && !emittedUrl) {
1068
+ const urlMatch = /^[ \t]+url[ \t]*=[ \t]*([^\r\n]+)/.exec(line);
1069
+ if (urlMatch) {
1070
+ lines.push(`${currentRemote} ${urlMatch[1].trim()} (fetch)`);
1071
+ emittedUrl = true;
816
1072
  }
817
- return { org: match[1], project };
818
1073
  }
819
1074
  }
820
- return null;
1075
+ return lines.join("\n");
821
1076
  }
822
1077
  function detectAzdoContext() {
823
- let remoteUrl;
1078
+ let configContent;
824
1079
  try {
825
- remoteUrl = execSync("git remote get-url origin", { encoding: "utf-8" }).trim();
1080
+ configContent = readGitConfigContent();
826
1081
  } catch {
827
1082
  throw new Error("Not in a git repository. Provide --org and --project explicitly.");
828
1083
  }
829
- const context = parseAzdoRemote(remoteUrl);
830
- if (!context || !context.org && !context.project) {
831
- throw new Error('Git remote "origin" is not an Azure DevOps URL. Provide --org and --project explicitly.');
1084
+ const remoteLines = gitConfigToRemoteLines(configContent);
1085
+ const candidates = parseAllAzdoRemotes(remoteLines);
1086
+ const selected = selectRemote(candidates);
1087
+ if (selected.hasEmbeddedSecret) {
1088
+ noticeCredentialBearingRemote(selected.remoteName);
832
1089
  }
833
- return context;
1090
+ return { org: selected.org, project: selected.project };
834
1091
  }
835
1092
  function parseRepoName(url) {
836
1093
  for (const pattern of patterns) {
837
1094
  const match = pattern.exec(url);
838
1095
  if (match) {
1096
+ if (httpsEmbeddedSecret.test(url)) {
1097
+ noticeCredentialBearingRemote();
1098
+ }
839
1099
  return match[3];
840
1100
  }
841
1101
  }
@@ -844,7 +1104,7 @@ function parseRepoName(url) {
844
1104
  function detectRepoName() {
845
1105
  let remoteUrl;
846
1106
  try {
847
- remoteUrl = execSync("git remote get-url origin", { encoding: "utf-8" }).trim();
1107
+ remoteUrl = execFileSync(GIT_BINARY, ["remote", "get-url", "origin"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
848
1108
  } catch {
849
1109
  throw new Error('Not in a git repository. Check that git remote "origin" exists and try again.');
850
1110
  }
@@ -855,7 +1115,7 @@ function detectRepoName() {
855
1115
  return repo;
856
1116
  }
857
1117
  function getCurrentBranch() {
858
- const branch = execSync("git rev-parse --abbrev-ref HEAD", { encoding: "utf-8" }).trim();
1118
+ const branch = execFileSync(GIT_BINARY, ["rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
859
1119
  if (branch === "HEAD") {
860
1120
  throw new Error("Not on a named branch. Check out a named branch and try again.");
861
1121
  }
@@ -891,7 +1151,7 @@ function formatResolutionError() {
891
1151
  return [
892
1152
  "Could not resolve an Azure DevOps organization. Options (in priority order):",
893
1153
  " 1. Pass --org <name> on the command line.",
894
- " 2. Run this command from a git repo whose origin remote is an Azure DevOps URL.",
1154
+ " 2. Run this command from a git repo that has an Azure DevOps remote.",
895
1155
  " 3. Run `azdo config set org <name>` once to set a persistent default."
896
1156
  ].join("\n");
897
1157
  }
@@ -904,9 +1164,9 @@ function resolveContext(options) {
904
1164
  gitContext = detectAzdoContext();
905
1165
  } catch {
906
1166
  }
907
- const config = loadConfig();
908
1167
  const org = resolvedOrg?.org;
909
- const project = options.project || (gitContext?.project && gitContext.project.length > 0 ? gitContext.project : void 0) || config.project;
1168
+ const scopedCfg = resolveScopedConfig(org);
1169
+ const project = options.project || (gitContext?.project && gitContext.project.length > 0 ? gitContext.project : void 0) || scopedCfg.project;
910
1170
  if (org && project) {
911
1171
  return { org, project };
912
1172
  }
@@ -1058,12 +1318,179 @@ function handleCommandError(err, id, context, scope = "write", exit = true) {
1058
1318
  }
1059
1319
  }
1060
1320
 
1061
- // src/commands/get-item.ts
1062
- function parseRequestedFields(raw) {
1063
- if (raw === void 0) return void 0;
1064
- const source = Array.isArray(raw) ? raw : [raw];
1065
- const tokens = source.flatMap((entry) => entry.split(/[,\s]+/)).map((field) => field.trim()).filter((field) => field.length > 0);
1066
- if (tokens.length === 0) return void 0;
1321
+ // src/services/image-download.ts
1322
+ import { Jimp } from "jimp";
1323
+ import { writeFile } from "fs/promises";
1324
+ import { existsSync as existsSync2 } from "fs";
1325
+ import { tmpdir } from "os";
1326
+ import { join as join2 } from "path";
1327
+ var ATTACHMENT_GUID_RE = /_apis\/wit\/attachments\/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})/;
1328
+ function isAzureDevOpsAttachmentHost(hostname) {
1329
+ const host = hostname.toLowerCase();
1330
+ return host === "dev.azure.com" || host.endsWith(".dev.azure.com") || host.endsWith(".visualstudio.com");
1331
+ }
1332
+ function decodeHtmlEntities(value) {
1333
+ return value.replaceAll("&quot;", '"').replaceAll("&#39;", "'").replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&amp;", "&");
1334
+ }
1335
+ function parseAttachmentReference(rawUrl, sourceField) {
1336
+ const url = decodeHtmlEntities(rawUrl.trim());
1337
+ let parsed;
1338
+ try {
1339
+ parsed = new URL(url);
1340
+ } catch {
1341
+ return null;
1342
+ }
1343
+ if (parsed.protocol !== "https:" || !isAzureDevOpsAttachmentHost(parsed.hostname)) {
1344
+ return null;
1345
+ }
1346
+ const match = ATTACHMENT_GUID_RE.exec(parsed.pathname);
1347
+ if (!match) return null;
1348
+ const guid = match[1].toLowerCase();
1349
+ let suggestedExtension = ".png";
1350
+ const fileName = parsed.searchParams.get("fileName");
1351
+ if (fileName?.includes(".")) {
1352
+ suggestedExtension = fileName.slice(fileName.lastIndexOf(".")).toLowerCase();
1353
+ }
1354
+ return { url, sourceField, guid, suggestedExtension };
1355
+ }
1356
+ function extractImageReferences(content, sourceField) {
1357
+ if (!content) return [];
1358
+ const references = [];
1359
+ const seen = /* @__PURE__ */ new Set();
1360
+ const add = (rawUrl) => {
1361
+ const reference = parseAttachmentReference(rawUrl, sourceField);
1362
+ if (reference && !seen.has(reference.guid)) {
1363
+ seen.add(reference.guid);
1364
+ references.push(reference);
1365
+ }
1366
+ };
1367
+ const imgRegex = /<img\b[^>]*?\ssrc\s*=\s*["']([^"']+)["']/gi;
1368
+ let match;
1369
+ while ((match = imgRegex.exec(content)) !== null) {
1370
+ add(match[1]);
1371
+ }
1372
+ const markdownRegex = /!\[[^\]]*\]\(\s*([^)\s]+)/g;
1373
+ while ((match = markdownRegex.exec(content)) !== null) {
1374
+ add(match[1]);
1375
+ }
1376
+ return references;
1377
+ }
1378
+ function addImageDownloadOptions(command) {
1379
+ return command.option("--download-images", "download images embedded in rich-text fields to local files").option("--resize-images <pixels>", "max image width in px; downloads and resizes embedded images to PNG (implies --download-images)").option("--images-path <dir>", "destination directory for downloaded images (default: system temp dir)");
1380
+ }
1381
+ function resolveImageDownloadOptionsOrExit(flags) {
1382
+ try {
1383
+ return resolveImageDownloadOptions(flags);
1384
+ } catch (err) {
1385
+ process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
1386
+ `);
1387
+ process.exit(1);
1388
+ }
1389
+ }
1390
+ function resolveImageDownloadOptions(flags) {
1391
+ const wantsResize = flags.resizeImages !== void 0;
1392
+ const enabled = Boolean(flags.downloadImages) || wantsResize;
1393
+ let maxWidth;
1394
+ if (wantsResize) {
1395
+ const parsed = Number(flags.resizeImages);
1396
+ if (!Number.isInteger(parsed) || parsed <= 0) {
1397
+ throw new Error(
1398
+ `Invalid --resize-images value "${flags.resizeImages}": must be a positive integer (max width in pixels).`
1399
+ );
1400
+ }
1401
+ maxWidth = parsed;
1402
+ }
1403
+ const outputDir = flags.imagesPath ?? tmpdir();
1404
+ if (flags.imagesPath !== void 0 && !existsSync2(outputDir)) {
1405
+ throw new Error(`Images path "${outputDir}" does not exist.`);
1406
+ }
1407
+ return { enabled, maxWidth, outputDir };
1408
+ }
1409
+ function buildImageFileName(workItemId, index, reference, resizing) {
1410
+ const ext = resizing ? ".png" : reference.suggestedExtension;
1411
+ return `wi-${workItemId}-${index}${ext}`;
1412
+ }
1413
+ async function processImageBytes(bytes, maxWidth) {
1414
+ if (maxWidth === void 0) {
1415
+ return { buffer: Buffer.from(bytes), resized: false, format: "original" };
1416
+ }
1417
+ const image = await Jimp.read(Buffer.from(bytes));
1418
+ let resized = false;
1419
+ if (image.bitmap.width > maxWidth) {
1420
+ image.resize({ w: maxWidth });
1421
+ resized = true;
1422
+ }
1423
+ const buffer = await image.getBuffer("image/png");
1424
+ return { buffer, resized, format: "png" };
1425
+ }
1426
+ async function downloadImagesFromFields(fields, args, credential) {
1427
+ const { workItemId, options } = args;
1428
+ const resizing = options.maxWidth !== void 0;
1429
+ const seen = /* @__PURE__ */ new Set();
1430
+ const references = [];
1431
+ for (const field of fields) {
1432
+ for (const reference of extractImageReferences(field.content, field.field)) {
1433
+ if (!seen.has(reference.guid)) {
1434
+ seen.add(reference.guid);
1435
+ references.push(reference);
1436
+ }
1437
+ }
1438
+ }
1439
+ const results = [];
1440
+ let index = 0;
1441
+ for (const reference of references) {
1442
+ index += 1;
1443
+ try {
1444
+ const bytes = await downloadAttachment(reference.url, credential);
1445
+ const processed = await processImageBytes(bytes, options.maxWidth);
1446
+ const fileName = buildImageFileName(workItemId, index, reference, resizing);
1447
+ const outputPath = join2(options.outputDir, fileName);
1448
+ await writeFile(outputPath, processed.buffer);
1449
+ results.push({
1450
+ reference,
1451
+ path: outputPath,
1452
+ resized: processed.resized,
1453
+ format: resizing ? "png" : reference.suggestedExtension.replace(/^\./, "")
1454
+ });
1455
+ } catch (err) {
1456
+ results.push({
1457
+ reference,
1458
+ resized: false,
1459
+ format: reference.suggestedExtension.replace(/^\./, ""),
1460
+ error: err instanceof Error ? err.message : String(err)
1461
+ });
1462
+ }
1463
+ }
1464
+ return results;
1465
+ }
1466
+ async function runImageDownload(fields, args, credential) {
1467
+ const results = await downloadImagesFromFields(fields, args, credential);
1468
+ process.stdout.write(formatImageSummary(results) + "\n");
1469
+ for (const result of results) {
1470
+ if (result.error) {
1471
+ process.stderr.write(`Failed to download image ${result.reference.url}: ${result.error}
1472
+ `);
1473
+ }
1474
+ }
1475
+ }
1476
+ function formatImageSummary(results) {
1477
+ if (results.length === 0) {
1478
+ return "Images: no images found in rich-text fields";
1479
+ }
1480
+ const saved = results.filter((r) => r.path);
1481
+ const lines = [`Images: ${saved.length} downloaded`];
1482
+ for (const result of saved) {
1483
+ lines.push(` ${result.path}`);
1484
+ }
1485
+ return lines.join("\n");
1486
+ }
1487
+
1488
+ // src/commands/get-item.ts
1489
+ function parseRequestedFields(raw) {
1490
+ if (raw === void 0) return void 0;
1491
+ const source = Array.isArray(raw) ? raw : [raw];
1492
+ const tokens = source.flatMap((entry) => entry.split(/[,\s]+/)).map((field) => field.trim()).filter((field) => field.length > 0);
1493
+ if (tokens.length === 0) return void 0;
1067
1494
  return Array.from(new Set(tokens));
1068
1495
  }
1069
1496
  function stripHtml(html) {
@@ -1073,12 +1500,12 @@ function stripHtml(html) {
1073
1500
  text = text.replaceAll(/<\/?(p|div)>/gi, "\n");
1074
1501
  text = text.replaceAll(/<li>/gi, "\n");
1075
1502
  text = removeHtmlTags(text);
1076
- text = text.replaceAll("&amp;", "&");
1077
1503
  text = text.replaceAll("&lt;", "<");
1078
1504
  text = text.replaceAll("&gt;", ">");
1079
1505
  text = text.replaceAll("&quot;", '"');
1080
1506
  text = text.replaceAll("&#39;", "'");
1081
1507
  text = text.replaceAll("&nbsp;", " ");
1508
+ text = text.replaceAll("&amp;", "&");
1082
1509
  text = text.replaceAll(/\n{3,}/g, "\n\n");
1083
1510
  return text.trim();
1084
1511
  }
@@ -1181,19 +1608,32 @@ function formatWorkItem(workItem, short, markdown = false) {
1181
1608
  }
1182
1609
  function createGetItemCommand() {
1183
1610
  const command = new Command("get-item");
1184
- command.description("Retrieve an Azure DevOps work item by ID").argument("<id>", "work item ID").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--short", "show abbreviated output").option("--fields <fields>", "comma-separated additional field reference names").option("--markdown", "convert rich text fields to markdown").action(
1611
+ command.description("Retrieve an Azure DevOps work item by ID").argument("<id>", "work item ID").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--short", "show abbreviated output").option("--fields <fields>", "comma-separated additional field reference names").option("--markdown", "convert rich text fields to markdown");
1612
+ addImageDownloadOptions(command);
1613
+ command.action(
1185
1614
  async (idStr, options) => {
1186
1615
  const id = parseWorkItemId(idStr);
1187
1616
  validateOrgProjectPair(options);
1617
+ const imageOptions = resolveImageDownloadOptionsOrExit(options);
1188
1618
  let context;
1189
1619
  try {
1190
1620
  context = resolveContext(options);
1191
- const credential = await requirePat(context.org);
1192
- const fieldsList = options.fields === void 0 ? parseRequestedFields(loadConfig().fields) : parseRequestedFields(options.fields);
1193
- const workItem = await getWorkItem(context, id, credential.pat, fieldsList);
1194
- const markdownEnabled = options.markdown ?? loadConfig().markdown ?? false;
1621
+ const credential = await requireAuthCredential(context.org);
1622
+ const scopedCfg = resolveScopedConfig(context.org);
1623
+ const fieldsList = options.fields === void 0 ? parseRequestedFields(scopedCfg.fields) : parseRequestedFields(options.fields);
1624
+ const workItem = await getWorkItem(context, id, credential, fieldsList);
1625
+ const markdownEnabled = options.markdown ?? scopedCfg.markdown ?? false;
1195
1626
  const output = formatWorkItem(workItem, options.short ?? false, markdownEnabled);
1196
1627
  process.stdout.write(output + "\n");
1628
+ if (imageOptions.enabled) {
1629
+ const fields = [{ content: workItem.description ?? "", field: "Description" }];
1630
+ if (workItem.extraFields) {
1631
+ for (const [name, value] of Object.entries(workItem.extraFields)) {
1632
+ fields.push({ content: value, field: name });
1633
+ }
1634
+ }
1635
+ await runImageDownload(fields, { workItemId: id, options: imageOptions }, credential);
1636
+ }
1197
1637
  } catch (err) {
1198
1638
  handleCommandError(err, id, context, "read", false);
1199
1639
  }
@@ -1230,59 +1670,75 @@ function createClearPatCommand() {
1230
1670
  // src/commands/auth.ts
1231
1671
  import { Command as Command3 } from "commander";
1232
1672
 
1233
- // src/services/browser-open.ts
1234
- import { execFile } from "child_process";
1235
- function isHeadless(platform, hasDisplay) {
1236
- if (platform === "linux") {
1237
- return !hasDisplay;
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) };
1238
1681
  }
1239
- return false;
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 };
1240
1694
  }
1241
- function commandForPlatform(platform) {
1242
- switch (platform) {
1243
- case "darwin":
1244
- return { cmd: "open", args: (url) => [url] };
1245
- case "win32":
1246
- return { cmd: "cmd", args: (url) => ["/c", "start", '""', url] };
1247
- case "linux":
1248
- return { cmd: "xdg-open", args: (url) => [url] };
1249
- default:
1250
- return null;
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
+ };
1251
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
+ };
1252
1718
  }
1253
- async function openUrl(url, opts = {}) {
1254
- const platform = opts.platform ?? process.platform;
1255
- const hasDisplay = opts.hasDisplay ?? (process.env.DISPLAY !== void 0 && process.env.DISPLAY !== "");
1256
- const forcePrint = opts.forcePrint ?? false;
1257
- if (forcePrint || isHeadless(platform, hasDisplay)) {
1258
- process.stderr.write(`Open this URL in your browser: ${url}
1259
- `);
1260
- return "printed";
1719
+ function formatDiagnosticReport(report, json) {
1720
+ if (json) {
1721
+ return JSON.stringify(report, null, 2);
1261
1722
  }
1262
- const spec = commandForPlatform(platform);
1263
- if (!spec) {
1264
- process.stderr.write(`Open this URL in your browser: ${url}
1265
- `);
1266
- return "printed";
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";
1267
1730
  }
1268
- const runner = opts.execFileFn ?? ((cmd, args, cb) => execFile(cmd, args, { timeout: 5e3 }, (err) => cb(err)));
1269
- return await new Promise((resolve2) => {
1270
- try {
1271
- runner(spec.cmd, spec.args(url), (err) => {
1272
- if (err) {
1273
- process.stderr.write(`Open this URL in your browser: ${url}
1274
- `);
1275
- resolve2("printed");
1276
- } else {
1277
- resolve2("opened");
1278
- }
1279
- });
1280
- } catch {
1281
- process.stderr.write(`Open this URL in your browser: ${url}
1282
- `);
1283
- resolve2("printed");
1284
- }
1285
- });
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");
1286
1742
  }
1287
1743
 
1288
1744
  // src/commands/auth.ts
@@ -1293,9 +1749,9 @@ async function readStdinToString() {
1293
1749
  }
1294
1750
  return Buffer.concat(chunks).toString("utf8");
1295
1751
  }
1296
- async function confirmOverwrite(org) {
1752
+ async function promptYesNo(prompt) {
1297
1753
  if (!process.stdin.isTTY) return true;
1298
- process.stderr.write(`A PAT is already stored for org ${org}. Overwrite? [y/N] `);
1754
+ process.stderr.write(prompt);
1299
1755
  return await new Promise((resolve2) => {
1300
1756
  process.stdin.setEncoding("utf8");
1301
1757
  let answered = false;
@@ -1311,7 +1767,23 @@ async function confirmOverwrite(org) {
1311
1767
  process.stdin.on("data", handler);
1312
1768
  });
1313
1769
  }
1314
- async function handleAuthRoot(options) {
1770
+ async function confirmOverwrite(org) {
1771
+ return promptYesNo(`A PAT is already stored for org ${org}. Overwrite? [y/N] `);
1772
+ }
1773
+ async function confirmOverwriteCredential(org, existingKind) {
1774
+ const label = existingKind === "oauth" ? "OAuth credential" : "PAT";
1775
+ return promptYesNo(`A ${label} is already stored for org ${org}. The new login will replace it. Continue? [y/N] `);
1776
+ }
1777
+ function rejectMutuallyExclusive(opts) {
1778
+ if (opts.usePat && opts.deviceCode) {
1779
+ return "--use-pat and --device-code are mutually exclusive (PAT has no device-code flow).";
1780
+ }
1781
+ if (opts.usePat && (opts.clientId || opts.tenantId || opts.scopes)) {
1782
+ return "--use-pat cannot be combined with OAuth-only flags (--client-id / --tenant-id / --scopes).";
1783
+ }
1784
+ return null;
1785
+ }
1786
+ async function handlePatLogin(options) {
1315
1787
  const resolved = resolveOrg({ org: options.org });
1316
1788
  if (!resolved) {
1317
1789
  process.stderr.write(`${formatResolutionError()}
@@ -1377,7 +1849,145 @@ async function handleAuthRoot(options) {
1377
1849
  process.stdout.write(`PAT stored for org ${org} in ${probeBackend()}.
1378
1850
  `);
1379
1851
  }
1852
+ async function ensureOverwriteConfirmed(org) {
1853
+ try {
1854
+ const existing = await getStoredCredential(org);
1855
+ if (existing === null || !process.stdin.isTTY) return "ok";
1856
+ const ok = await confirmOverwriteCredential(org, existing.kind);
1857
+ return ok ? "ok" : "aborted";
1858
+ } catch (err) {
1859
+ if (err instanceof CredentialStoreUnavailableError) {
1860
+ process.stderr.write(`${err.message}
1861
+ `);
1862
+ return "unavailable";
1863
+ }
1864
+ throw err;
1865
+ }
1866
+ }
1867
+ function buildOAuthLoginOptions(options) {
1868
+ return {
1869
+ flow: options.deviceCode ? "device-code" : "auto",
1870
+ clientIdOverride: options.clientId,
1871
+ tenantIdOverride: options.tenantId,
1872
+ scopesOverride: options.scopes ? options.scopes.split(/\s+/).filter(Boolean) : void 0
1873
+ };
1874
+ }
1875
+ function reportOAuthFailure(err) {
1876
+ const reason = typeof err === "object" && err !== null && "reason" in err ? err.reason : null;
1877
+ const msg = err.message;
1878
+ process.stderr.write(reason ? `OAuth login failed (${reason}): ${msg}
1879
+ ` : `OAuth login failed: ${msg}
1880
+ `);
1881
+ const noDisplay = process.platform === "linux" && (!process.env.DISPLAY || process.env.DISPLAY.length === 0);
1882
+ if (noDisplay) {
1883
+ process.stderr.write("Tip: this host has no DISPLAY; pass --device-code to use the headless flow.\n");
1884
+ } else if (reason === "port-conflict") {
1885
+ process.stderr.write("Tip: another process is using the loopback callback port. Try again or pass --device-code.\n");
1886
+ }
1887
+ }
1888
+ async function handleOAuthLogin(options) {
1889
+ const resolved = resolveOrg({ org: options.org });
1890
+ if (!resolved) {
1891
+ process.stderr.write(`${formatResolutionError()}
1892
+ `);
1893
+ process.exitCode = 3;
1894
+ return;
1895
+ }
1896
+ const org = resolved.org;
1897
+ const confirm = await ensureOverwriteConfirmed(org);
1898
+ if (confirm === "aborted") {
1899
+ process.stderr.write("Aborted. Existing credential preserved.\n");
1900
+ process.exitCode = 1;
1901
+ return;
1902
+ }
1903
+ if (confirm === "unavailable") {
1904
+ process.exitCode = 4;
1905
+ return;
1906
+ }
1907
+ try {
1908
+ const result = await loginWithOAuth(org, buildOAuthLoginOptions(options));
1909
+ process.stdout.write(
1910
+ `Logged in to ${org} via OAuth (${result.flowUsed}). Account: ${result.accountId}; expires ${new Date(result.expiresAt * 1e3).toISOString()}.
1911
+ `
1912
+ );
1913
+ } catch (err) {
1914
+ reportOAuthFailure(err);
1915
+ process.exitCode = 1;
1916
+ }
1917
+ }
1918
+ async function handleAuthRoot(options) {
1919
+ const conflict = rejectMutuallyExclusive(options);
1920
+ if (conflict) {
1921
+ process.stderr.write(`${conflict}
1922
+ `);
1923
+ process.exitCode = 2;
1924
+ return;
1925
+ }
1926
+ await handlePatLogin(options);
1927
+ }
1928
+ async function handleLoginSubcommand(options) {
1929
+ const conflict = rejectMutuallyExclusive(options);
1930
+ if (conflict) {
1931
+ process.stderr.write(`${conflict}
1932
+ `);
1933
+ process.exitCode = 2;
1934
+ return;
1935
+ }
1936
+ if (options.fromStdin || options.usePat) {
1937
+ await handlePatLogin(options);
1938
+ return;
1939
+ }
1940
+ await handleOAuthLogin(options);
1941
+ }
1942
+ async function handleStatusJson() {
1943
+ try {
1944
+ const report = await status();
1945
+ process.stdout.write(`${JSON.stringify(report)}
1946
+ `);
1947
+ } catch (err) {
1948
+ if (err instanceof CredentialStoreUnavailableError) {
1949
+ process.stderr.write(`${err.message}
1950
+ `);
1951
+ process.exitCode = 4;
1952
+ return;
1953
+ }
1954
+ throw err;
1955
+ }
1956
+ }
1380
1957
  async function handleStatus(options, org) {
1958
+ if (options.json) {
1959
+ let backend2;
1960
+ let value2;
1961
+ try {
1962
+ backend2 = probeBackend();
1963
+ value2 = await getPat(org);
1964
+ } catch (err) {
1965
+ if (err instanceof CredentialStoreUnavailableError) {
1966
+ process.stderr.write(`${err.message}
1967
+ `);
1968
+ process.exitCode = 4;
1969
+ return;
1970
+ }
1971
+ throw err;
1972
+ }
1973
+ const storedEvents2 = readAuditEvents().filter((ev) => ev.org === org && ev.event === "auth.store");
1974
+ const last2 = storedEvents2.at(-1);
1975
+ const updatedAt2 = last2?.ts ?? null;
1976
+ if (!value2) {
1977
+ process.stdout.write(
1978
+ `${JSON.stringify({ org, backend: backend2, stored: false, masked: null, updated_at: updatedAt2 })}
1979
+ `
1980
+ );
1981
+ process.exitCode = 1;
1982
+ return;
1983
+ }
1984
+ const masked2 = maskedDisplay(value2);
1985
+ process.stdout.write(
1986
+ `${JSON.stringify({ org, backend: backend2, stored: true, masked: masked2, updated_at: updatedAt2 })}
1987
+ `
1988
+ );
1989
+ return;
1990
+ }
1381
1991
  let backend;
1382
1992
  let value;
1383
1993
  try {
@@ -1393,39 +2003,25 @@ async function handleStatus(options, org) {
1393
2003
  throw err;
1394
2004
  }
1395
2005
  const storedEvents = readAuditEvents().filter((ev) => ev.org === org && ev.event === "auth.store");
1396
- const last = storedEvents[storedEvents.length - 1];
2006
+ const last = storedEvents.at(-1);
1397
2007
  const updatedAt = last?.ts ?? null;
1398
2008
  if (!value) {
1399
- if (options.json) {
1400
- process.stdout.write(
1401
- `${JSON.stringify({ org, backend, stored: false, masked: null, updated_at: updatedAt })}
1402
- `
1403
- );
1404
- } else {
1405
- process.stdout.write(`Organization: ${org}
2009
+ process.stdout.write(`Organization: ${org}
1406
2010
  Backend: ${backend}
1407
2011
  Stored: no
1408
2012
  `);
1409
- }
1410
2013
  process.exitCode = 1;
1411
2014
  return;
1412
2015
  }
1413
2016
  const masked = maskedDisplay(value);
1414
- if (options.json) {
1415
- process.stdout.write(
1416
- `${JSON.stringify({ org, backend, stored: true, masked, updated_at: updatedAt })}
1417
- `
1418
- );
1419
- } else {
1420
- process.stdout.write(
1421
- `Organization: ${org}
2017
+ process.stdout.write(
2018
+ `Organization: ${org}
1422
2019
  Backend: ${backend}
1423
2020
  Stored: yes
1424
2021
  Identifier: ${masked}
1425
2022
  ` + (updatedAt ? `Last updated: ${updatedAt}
1426
2023
  ` : "")
1427
- );
1428
- }
2024
+ );
1429
2025
  }
1430
2026
  async function handleLogout(options, orgFromGlobal) {
1431
2027
  if (options.all && orgFromGlobal) {
@@ -1434,9 +2030,16 @@ async function handleLogout(options, orgFromGlobal) {
1434
2030
  return;
1435
2031
  }
1436
2032
  if (options.all) {
1437
- let orgs;
1438
2033
  try {
1439
- orgs = await listOrgsWithStoredPat();
2034
+ const result = await logout({ all: true });
2035
+ if (result.removed.length === 0) {
2036
+ process.stdout.write("No stored credentials to remove.\n");
2037
+ return;
2038
+ }
2039
+ for (const r of result.removed) {
2040
+ process.stdout.write(`Removed ${r.kind} credential for org ${r.org}.
2041
+ `);
2042
+ }
1440
2043
  } catch (err) {
1441
2044
  if (err instanceof CredentialStoreUnavailableError) {
1442
2045
  process.stderr.write(`${err.message}
@@ -1444,22 +2047,9 @@ async function handleLogout(options, orgFromGlobal) {
1444
2047
  process.exitCode = 4;
1445
2048
  return;
1446
2049
  }
1447
- throw err;
1448
- }
1449
- if (orgs.length === 0) {
1450
- process.stdout.write("No stored PATs to remove.\n");
1451
- return;
1452
- }
1453
- for (const org of orgs) {
1454
- try {
1455
- await deletePat(org);
1456
- process.stdout.write(`PAT removed for org ${org}.
1457
- `);
1458
- } catch (err) {
1459
- process.stderr.write(`Failed to remove PAT for org ${org}: ${err.message}
2050
+ process.stderr.write(`Failed to remove credentials: ${err.message}
1460
2051
  `);
1461
- process.exitCode = 1;
1462
- }
2052
+ process.exitCode = 1;
1463
2053
  }
1464
2054
  return;
1465
2055
  }
@@ -1473,10 +2063,10 @@ async function handleLogout(options, orgFromGlobal) {
1473
2063
  try {
1474
2064
  const removed = await deletePat(resolved.org);
1475
2065
  if (removed) {
1476
- process.stdout.write(`PAT removed for org ${resolved.org}.
2066
+ process.stdout.write(`Credential removed for org ${resolved.org}.
1477
2067
  `);
1478
2068
  } else {
1479
- process.stdout.write(`No stored PAT found for org ${resolved.org}.
2069
+ process.stdout.write(`No stored credential for org ${resolved.org}.
1480
2070
  `);
1481
2071
  }
1482
2072
  } catch (err) {
@@ -1491,14 +2081,70 @@ async function handleLogout(options, orgFromGlobal) {
1491
2081
  }
1492
2082
  function createAuthCommand() {
1493
2083
  const command = new Command3("auth");
1494
- command.description("Manage Azure DevOps Personal Access Tokens (PAT) in the OS secret vault");
1495
- command.option("--org <name>", "Azure DevOps organization (flag wins over auto-detect / config)").option("--from-stdin", "read PAT from stdin instead of prompting", false).option("--no-browser", "do not open the Azure DevOps PAT page in a browser");
1496
- command.action(async (options) => {
2084
+ command.description(
2085
+ "Manage Azure DevOps authentication. Use `azdo auth login` for OAuth (default); the bare `azdo auth` form preserves the legacy PAT-prompt path for back-compat."
2086
+ );
2087
+ command.option("--org <name>", "Azure DevOps organization (flag wins over auto-detect / config)").option("--from-stdin", "read PAT from stdin instead of prompting (implies --use-pat)", false).option("--no-browser", "do not open the Azure DevOps PAT page in a browser (PAT path only)").option("--use-pat", "use Personal Access Token instead of OAuth (legacy path)", false).option("--device-code", "use OAuth device-code flow (headless hosts; OAuth only)", false).option("--client-id <id>", "override the default OAuth client id (FR-013 override path)").option("--tenant-id <id>", "override the default OAuth tenant id (default: common)").option("--scopes <scopes>", "space-separated OAuth scope override (advanced; default mirrors PAT scope table)");
2088
+ command.addHelpText(
2089
+ "after",
2090
+ `
2091
+ Default flow (OAuth, browser-based):
2092
+ azdo auth login --org <name>
2093
+ \u2192 opens the default browser for OAuth (Microsoft Entra v2 + PKCE).
2094
+
2095
+ Headless / no-browser:
2096
+ azdo auth login --org <name> --device-code
2097
+
2098
+ PAT path (legacy, opt-in):
2099
+ azdo auth login --org <name> --use-pat
2100
+ azdo auth --org <name> # back-compat alias of the above
2101
+
2102
+ OAuth scope set \u2014 shipped first-party client (default install):
2103
+ ${firstPartyShippedScopes().join("\n ")}
2104
+ (uses ${AZDO_RESOURCE_ID}/.default \u2014 per-scope consent is unavailable
2105
+ against a client we do not own; .default grants the VS client's
2106
+ pre-authorized AzDO permissions in one step.)
2107
+
2108
+ OAuth scope set \u2014 self-registered apps (--client-id / AZDO_OAUTH_CLIENT_ID):
2109
+ ${defaultScopes().join("\n ")}
2110
+ (FR-016, mirrors the PAT scope table \u2014 see docs/oauth-app-registration.md)
2111
+
2112
+ For self-registered OAuth apps (locked-down tenants), see docs/oauth-app-registration.md
2113
+ \u2014 that same guide is the maintainer reference for the project's shared client id.
2114
+
2115
+ Note: stored credentials may coexist as 'pat' or 'oauth' across orgs (FR-007).
2116
+ Note: \`azdo auth\` (no subcommand) preserves the legacy PAT-prompt entry point;
2117
+ \`azdo auth login\` is the spec-canonical name and defaults to OAuth.`
2118
+ ).action(async (options) => {
1497
2119
  await handleAuthRoot(options);
1498
2120
  });
1499
- const statusCmd = command.command("status").description("Report whether a PAT is stored for the resolved org (masked, never the full value)").option("--json", "emit a JSON object", false);
2121
+ const loginCmd = command.command("login").description("Authenticate against Azure DevOps (OAuth default; --use-pat for PAT)").option("--org <name>", "Azure DevOps organization (defaults: git remote \u2192 config)");
2122
+ loginCmd.action(async () => {
2123
+ const merged = loginCmd.optsWithGlobals();
2124
+ await handleLoginSubcommand(merged);
2125
+ });
2126
+ const statusCmd = command.command("status").description("Report stored credentials (kind, org, account/expiry, backend) \u2014 never the token").option("--json", "emit JSON", false);
1500
2127
  statusCmd.action(async (options) => {
1501
2128
  const globals = statusCmd.optsWithGlobals();
2129
+ if (!globals.org) {
2130
+ if (options.json) {
2131
+ await handleStatusJson();
2132
+ return;
2133
+ }
2134
+ const report = await status();
2135
+ if (report.orgs.length === 0) {
2136
+ process.stdout.write("No stored credentials.\n");
2137
+ return;
2138
+ }
2139
+ for (const e of report.orgs) {
2140
+ const expiry = e.expiresAt ? new Date(e.expiresAt * 1e3).toISOString() : "n/a";
2141
+ process.stdout.write(
2142
+ `${e.org} ${e.kind} ${e.accountId ?? ""} ${expiry}
2143
+ `
2144
+ );
2145
+ }
2146
+ return;
2147
+ }
1502
2148
  const resolved = resolveOrg({ org: globals.org });
1503
2149
  if (!resolved) {
1504
2150
  process.stderr.write(`${formatResolutionError()}
@@ -1508,11 +2154,34 @@ function createAuthCommand() {
1508
2154
  }
1509
2155
  await handleStatus(options, resolved.org);
1510
2156
  });
1511
- const logoutCmd = command.command("logout").description("Remove the stored PAT for an org (or all orgs with --all)").option("--all", "remove the stored PAT for every org", false);
2157
+ statusCmd.addHelpText(
2158
+ "after",
2159
+ "\nStored credentials may be of kind `pat` or `oauth` and may coexist across orgs (FR-007).\n"
2160
+ );
2161
+ const logoutCmd = command.command("logout").description("Remove the stored credential for an org (or all orgs with --all)").option("--all", "remove every stored credential (PAT and OAuth)", false);
1512
2162
  logoutCmd.action(async (options) => {
1513
2163
  const globals = logoutCmd.optsWithGlobals();
1514
2164
  await handleLogout(options, globals.org);
1515
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
+ });
1516
2185
  return command;
1517
2186
  }
1518
2187
 
@@ -1525,21 +2194,50 @@ function formatConfigValue(value, unsetFallback = "") {
1525
2194
  }
1526
2195
  return Array.isArray(value) ? value.join(",") : value;
1527
2196
  }
2197
+ function buildConfigListEntries(cfg) {
2198
+ const entries = SETTINGS.map((s) => ({
2199
+ scope: "default",
2200
+ key: s.key,
2201
+ value: cfg[s.key]
2202
+ }));
2203
+ for (const [orgName, scope] of Object.entries(cfg.organizations ?? {})) {
2204
+ for (const [k, v] of Object.entries(scope)) {
2205
+ entries.push({ scope: orgName, key: k, value: v });
2206
+ }
2207
+ }
2208
+ return entries;
2209
+ }
1528
2210
  function writeConfigList(cfg) {
1529
2211
  const keyWidth = 10;
1530
2212
  const valueWidth = 30;
2213
+ const scopeWidth = 12;
2214
+ process.stdout.write(
2215
+ `${"scope".padEnd(scopeWidth)}${"key".padEnd(keyWidth)}${"value".padEnd(valueWidth)}description
2216
+ `
2217
+ );
1531
2218
  for (const setting of SETTINGS) {
1532
2219
  const raw = cfg[setting.key];
1533
2220
  const value = formatConfigValue(raw, "(not set)");
1534
2221
  const marker = raw === void 0 && setting.required ? " *" : "";
1535
2222
  process.stdout.write(
1536
- `${setting.key.padEnd(keyWidth)}${String(value).padEnd(valueWidth)}${setting.description}${marker}
2223
+ `${"default".padEnd(scopeWidth)}${setting.key.padEnd(keyWidth)}${String(value).padEnd(valueWidth)}${setting.description}${marker}
1537
2224
  `
1538
2225
  );
1539
2226
  }
1540
- const hasUnset = SETTINGS.some((s) => s.required && cfg[s.key] === void 0);
1541
- if (hasUnset) {
1542
- process.stdout.write(
2227
+ for (const [orgName, scope] of Object.entries(cfg.organizations ?? {})) {
2228
+ const orgScope = scope;
2229
+ const scopedSettings = Object.entries(orgScope);
2230
+ for (const [k, v] of scopedSettings) {
2231
+ const value = formatConfigValue(v, "(not set)");
2232
+ process.stdout.write(
2233
+ `${orgName.padEnd(scopeWidth)}${k.padEnd(keyWidth)}${String(value).padEnd(valueWidth)}
2234
+ `
2235
+ );
2236
+ }
2237
+ }
2238
+ const hasUnset = SETTINGS.some((s) => s.required && cfg[s.key] === void 0);
2239
+ if (hasUnset) {
2240
+ process.stdout.write(
1543
2241
  '\n* = required but not configured. Run "azdo config wizard" to set up.\n'
1544
2242
  );
1545
2243
  }
@@ -1580,17 +2278,22 @@ function createConfigCommand() {
1580
2278
  const config = new Command4("config");
1581
2279
  config.description("Manage CLI settings");
1582
2280
  const set = new Command4("set");
1583
- set.description("Set a configuration value").argument("<key>", "setting key (org, project, fields)").argument("<value>", "setting value").option("--json", "output in JSON format").action((key, value, options) => {
2281
+ set.description("Set a configuration value").argument("<key>", "setting key (org, project, fields, markdown)").argument("<value>", "setting value").option("--org <org>", "set value in an org-scoped configuration").option("--json", "output in JSON format").action((key, value, options) => {
1584
2282
  try {
1585
- setConfigValue(key, value);
2283
+ if (options.org) {
2284
+ setOrgScopedValue(options.org, key, value);
2285
+ } else {
2286
+ setConfigValue(key, value);
2287
+ }
1586
2288
  if (options.json) {
1587
- const output = { key, value };
2289
+ const output = { key, value, scope: options.org ?? "default" };
1588
2290
  if (key === "fields") {
1589
2291
  output.value = value.split(",").map((s) => s.trim());
1590
2292
  }
1591
2293
  process.stdout.write(JSON.stringify(output) + "\n");
1592
2294
  } else {
1593
- process.stdout.write(`Set "${key}" to "${value}"
2295
+ const scopeTag = options.org ? ` (org: ${options.org})` : "";
2296
+ process.stdout.write(`Set "${key}" to "${value}"${scopeTag}
1594
2297
  `);
1595
2298
  }
1596
2299
  } catch (err) {
@@ -1601,12 +2304,12 @@ function createConfigCommand() {
1601
2304
  }
1602
2305
  });
1603
2306
  const get = new Command4("get");
1604
- get.description("Get a configuration value").argument("<key>", "setting key (org, project, fields)").option("--json", "output in JSON format").action((key, options) => {
2307
+ get.description("Get a configuration value").argument("<key>", "setting key (org, project, fields, markdown)").option("--org <org>", "read from an org-scoped configuration").option("--json", "output in JSON format").action((key, options) => {
1605
2308
  try {
1606
- const value = getConfigValue(key);
2309
+ const value = options.org ? getOrgScopedValue(options.org, key) : getConfigValue(key);
1607
2310
  if (options.json) {
1608
2311
  process.stdout.write(
1609
- JSON.stringify({ key, value: value ?? null }) + "\n"
2312
+ JSON.stringify({ key, value: value ?? null, scope: options.org ?? "default" }) + "\n"
1610
2313
  );
1611
2314
  } else if (value === void 0) {
1612
2315
  process.stdout.write(`Setting "${key}" is not configured.
@@ -1614,7 +2317,7 @@ function createConfigCommand() {
1614
2317
  } else if (Array.isArray(value)) {
1615
2318
  process.stdout.write(value.join(",") + "\n");
1616
2319
  } else {
1617
- process.stdout.write(value + "\n");
2320
+ process.stdout.write(String(value) + "\n");
1618
2321
  }
1619
2322
  } catch (err) {
1620
2323
  const message = err instanceof Error ? err.message : String(err);
@@ -1627,19 +2330,25 @@ function createConfigCommand() {
1627
2330
  list.description("List all configuration values").option("--json", "output in JSON format").action((options) => {
1628
2331
  const cfg = loadConfig();
1629
2332
  if (options.json) {
1630
- process.stdout.write(JSON.stringify(cfg) + "\n");
2333
+ const entries = buildConfigListEntries(cfg);
2334
+ process.stdout.write(JSON.stringify(entries) + "\n");
1631
2335
  return;
1632
2336
  }
1633
2337
  writeConfigList(cfg);
1634
2338
  });
1635
2339
  const unset = new Command4("unset");
1636
- unset.description("Remove a configuration value").argument("<key>", "setting key (org, project, fields)").option("--json", "output in JSON format").action((key, options) => {
2340
+ unset.description("Remove a configuration value").argument("<key>", "setting key (org, project, fields, markdown)").option("--org <org>", "remove from an org-scoped configuration").option("--json", "output in JSON format").action((key, options) => {
1637
2341
  try {
1638
- unsetConfigValue(key);
2342
+ if (options.org) {
2343
+ unsetOrgScopedValue(options.org, key);
2344
+ } else {
2345
+ unsetConfigValue(key);
2346
+ }
1639
2347
  if (options.json) {
1640
- process.stdout.write(JSON.stringify({ key, unset: true }) + "\n");
2348
+ process.stdout.write(JSON.stringify({ key, unset: true, scope: options.org ?? "default" }) + "\n");
1641
2349
  } else {
1642
- process.stdout.write(`Unset "${key}"
2350
+ const scopeTag = options.org ? ` (org: ${options.org})` : "";
2351
+ process.stdout.write(`Unset "${key}"${scopeTag}
1643
2352
  `);
1644
2353
  }
1645
2354
  } catch (err) {
@@ -1671,10 +2380,52 @@ function createConfigCommand() {
1671
2380
  rl.close();
1672
2381
  process.stderr.write("Configuration complete!\n");
1673
2382
  });
2383
+ const orgCopy = new Command4("org-copy");
2384
+ orgCopy.description('Copy settings from one scope to another (use "default" as source to copy top-level settings)').argument("<from>", 'source scope name or "default"').argument("<to>", "destination org name").option("--force", "overwrite existing values on collision").action((from, to, options) => {
2385
+ try {
2386
+ copyOrgScope(from, to, options.force ?? false);
2387
+ process.stdout.write(`Copied scope "${from}" to "${to}"
2388
+ `);
2389
+ } catch (err) {
2390
+ const message = err instanceof Error ? err.message : String(err);
2391
+ process.stderr.write(`Error: ${message}
2392
+ `);
2393
+ process.exit(1);
2394
+ }
2395
+ });
2396
+ const orgMove = new Command4("org-move");
2397
+ orgMove.description("Move settings from one org scope to another").argument("<from>", "source org name").argument("<to>", "destination org name").option("--force", "overwrite existing values on collision").action((from, to, options) => {
2398
+ try {
2399
+ moveOrgScope(from, to, options.force ?? false);
2400
+ process.stdout.write(`Moved scope "${from}" to "${to}"
2401
+ `);
2402
+ } catch (err) {
2403
+ const message = err instanceof Error ? err.message : String(err);
2404
+ process.stderr.write(`Error: ${message}
2405
+ `);
2406
+ process.exit(1);
2407
+ }
2408
+ });
2409
+ const orgDelete = new Command4("org-delete");
2410
+ orgDelete.description("Delete an org-scoped configuration").argument("<name>", "org name").action((name) => {
2411
+ try {
2412
+ deleteOrgScope(name);
2413
+ process.stdout.write(`Deleted scope "${name}"
2414
+ `);
2415
+ } catch (err) {
2416
+ const message = err instanceof Error ? err.message : String(err);
2417
+ process.stderr.write(`Error: ${message}
2418
+ `);
2419
+ process.exit(1);
2420
+ }
2421
+ });
1674
2422
  config.addCommand(set);
1675
2423
  config.addCommand(get);
1676
2424
  config.addCommand(list);
1677
2425
  config.addCommand(unset);
2426
+ config.addCommand(orgCopy);
2427
+ config.addCommand(orgMove);
2428
+ config.addCommand(orgDelete);
1678
2429
  config.addCommand(wizard);
1679
2430
  return config;
1680
2431
  }
@@ -1690,11 +2441,11 @@ function createSetStateCommand() {
1690
2441
  let context;
1691
2442
  try {
1692
2443
  context = resolveContext(options);
1693
- const credential = await requirePat(context.org);
2444
+ const credential = await requireAuthCredential(context.org);
1694
2445
  const operations = [
1695
2446
  { op: "add", path: "/fields/System.State", value: state }
1696
2447
  ];
1697
- const result = await updateWorkItem(context, id, credential.pat, "System.State", operations);
2448
+ const result = await updateWorkItem(context, id, credential, "System.State", operations);
1698
2449
  if (options.json) {
1699
2450
  process.stdout.write(
1700
2451
  JSON.stringify({
@@ -1740,12 +2491,12 @@ function createAssignCommand() {
1740
2491
  let context;
1741
2492
  try {
1742
2493
  context = resolveContext(options);
1743
- const credential = await requirePat(context.org);
2494
+ const credential = await requireAuthCredential(context.org);
1744
2495
  const value = options.unassign ? "" : name;
1745
2496
  const operations = [
1746
2497
  { op: "add", path: "/fields/System.AssignedTo", value }
1747
2498
  ];
1748
- const result = await updateWorkItem(context, id, credential.pat, "System.AssignedTo", operations);
2499
+ const result = await updateWorkItem(context, id, credential, "System.AssignedTo", operations);
1749
2500
  if (options.json) {
1750
2501
  process.stdout.write(
1751
2502
  JSON.stringify({
@@ -1780,11 +2531,11 @@ function createSetFieldCommand() {
1780
2531
  let context;
1781
2532
  try {
1782
2533
  context = resolveContext(options);
1783
- const credential = await requirePat(context.org);
2534
+ const credential = await requireAuthCredential(context.org);
1784
2535
  const operations = [
1785
2536
  { op: "add", path: `/fields/${field}`, value }
1786
2537
  ];
1787
- const result = await updateWorkItem(context, id, credential.pat, field, operations);
2538
+ const result = await updateWorkItem(context, id, credential, field, operations);
1788
2539
  if (options.json) {
1789
2540
  process.stdout.write(
1790
2541
  JSON.stringify({
@@ -1811,20 +2562,30 @@ function createSetFieldCommand() {
1811
2562
  import { Command as Command8 } from "commander";
1812
2563
  function createGetMdFieldCommand() {
1813
2564
  const command = new Command8("get-md-field");
1814
- command.description("Get a work item field value, converting HTML to markdown").argument("<id>", "work item ID").argument("<field>", "field reference name (e.g., System.Description)").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").action(
2565
+ command.description("Get a work item field value, converting HTML to markdown").argument("<id>", "work item ID").argument("<field>", "field reference name (e.g., System.Description)").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project");
2566
+ addImageDownloadOptions(command);
2567
+ command.action(
1815
2568
  async (idStr, field, options) => {
1816
2569
  const id = parseWorkItemId(idStr);
1817
2570
  validateOrgProjectPair(options);
2571
+ const imageOptions = resolveImageDownloadOptionsOrExit(options);
1818
2572
  let context;
1819
2573
  try {
1820
2574
  context = resolveContext(options);
1821
- const credential = await requirePat(context.org);
1822
- const value = await getWorkItemFieldValue(context, id, credential.pat, field);
2575
+ const credential = await requireAuthCredential(context.org);
2576
+ const value = await getWorkItemFieldValue(context, id, credential, field);
1823
2577
  if (value === null) {
1824
2578
  process.stdout.write("\n");
1825
2579
  } else {
1826
2580
  process.stdout.write(toMarkdown(value) + "\n");
1827
2581
  }
2582
+ if (imageOptions.enabled) {
2583
+ await runImageDownload(
2584
+ [{ content: value ?? "", field }],
2585
+ { workItemId: id, options: imageOptions },
2586
+ credential
2587
+ );
2588
+ }
1828
2589
  } catch (err) {
1829
2590
  handleCommandError(err, id, context, "read");
1830
2591
  }
@@ -1834,7 +2595,7 @@ function createGetMdFieldCommand() {
1834
2595
  }
1835
2596
 
1836
2597
  // src/commands/set-md-field.ts
1837
- import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
2598
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
1838
2599
  import { Command as Command9 } from "commander";
1839
2600
  function fail(message) {
1840
2601
  process.stderr.write(`Error: ${message}
@@ -1854,7 +2615,7 @@ function resolveContent(inlineContent, options) {
1854
2615
  return null;
1855
2616
  }
1856
2617
  function readFileContent(filePath) {
1857
- if (!existsSync2(filePath)) {
2618
+ if (!existsSync3(filePath)) {
1858
2619
  fail(`File not found: ${filePath}`);
1859
2620
  }
1860
2621
  try {
@@ -1906,12 +2667,12 @@ function createSetMdFieldCommand() {
1906
2667
  let context;
1907
2668
  try {
1908
2669
  context = resolveContext(options);
1909
- const credential = await requirePat(context.org);
2670
+ const credential = await requireAuthCredential(context.org);
1910
2671
  const operations = [
1911
2672
  { op: "add", path: `/fields/${field}`, value: content },
1912
2673
  { op: "add", path: `/multilineFieldsFormat/${field}`, value: "Markdown" }
1913
2674
  ];
1914
- const result = await updateWorkItem(context, id, credential.pat, field, operations);
2675
+ const result = await updateWorkItem(context, id, credential, field, operations);
1915
2676
  formatOutput(result, options, field);
1916
2677
  } catch (err) {
1917
2678
  handleCommandError(err, id, context, "write");
@@ -1922,7 +2683,7 @@ function createSetMdFieldCommand() {
1922
2683
  }
1923
2684
 
1924
2685
  // src/commands/upsert.ts
1925
- import { existsSync as existsSync3, readFileSync as readFileSync4, unlinkSync } from "fs";
2686
+ import { existsSync as existsSync4, readFileSync as readFileSync4, unlinkSync } from "fs";
1926
2687
  import { Command as Command10 } from "commander";
1927
2688
 
1928
2689
  // src/services/task-document.ts
@@ -2086,7 +2847,7 @@ function loadSourceContent(options) {
2086
2847
  return { content: options.content };
2087
2848
  }
2088
2849
  const filePath = options.file;
2089
- if (!existsSync3(filePath)) {
2850
+ if (!existsSync4(filePath)) {
2090
2851
  fail2(`File not found: ${filePath}`);
2091
2852
  }
2092
2853
  try {
@@ -2222,15 +2983,15 @@ function createUpsertCommand() {
2222
2983
  ensureTitleForCreate(document.fields);
2223
2984
  }
2224
2985
  const operations = toPatchOperations(document.fields, action);
2225
- const credential = await requirePat(context.org);
2986
+ const credential = await requireAuthCredential(context.org);
2226
2987
  let writeResult;
2227
2988
  if (action === "created") {
2228
- writeResult = await createWorkItem(context, createType, credential.pat, operations);
2989
+ writeResult = await createWorkItem(context, createType, credential, operations);
2229
2990
  } else {
2230
2991
  if (id === void 0) {
2231
2992
  fail2("Work item ID is required for updates.");
2232
2993
  }
2233
- writeResult = await applyWorkItemPatch(context, id, credential.pat, operations);
2994
+ writeResult = await applyWorkItemPatch(context, id, credential, operations);
2234
2995
  }
2235
2996
  const result = buildUpsertResult(
2236
2997
  action,
@@ -2288,8 +3049,8 @@ function createListFieldsCommand() {
2288
3049
  let context;
2289
3050
  try {
2290
3051
  context = resolveContext(options);
2291
- const credential = await requirePat(context.org);
2292
- const fields = await getWorkItemFields(context, id, credential.pat);
3052
+ const credential = await requireAuthCredential(context.org);
3053
+ const fields = await getWorkItemFields(context, id, credential);
2293
3054
  if (options.json) {
2294
3055
  process.stdout.write(JSON.stringify({ id, fields }, null, 2) + "\n");
2295
3056
  } else {
@@ -2331,6 +3092,31 @@ function buildPullRequestStatusesUrl(context, repo, prId) {
2331
3092
  url.searchParams.set("api-version", "7.1");
2332
3093
  return url;
2333
3094
  }
3095
+ function buildProjectUrl(context) {
3096
+ const url = new URL(
3097
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/_apis/projects/${encodeURIComponent(context.project)}`
3098
+ );
3099
+ url.searchParams.set("api-version", "7.1");
3100
+ return url;
3101
+ }
3102
+ function buildPolicyEvaluationsUrl(context, projectId, prId) {
3103
+ const url = new URL(
3104
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/policy/evaluations`
3105
+ );
3106
+ url.searchParams.set("api-version", "7.1");
3107
+ url.searchParams.set("artifactId", `vstfs:///CodeReview/CodeReviewId/${projectId}/${prId}`);
3108
+ return url;
3109
+ }
3110
+ function buildPullRequestBuildsUrl(context, prId) {
3111
+ const url = new URL(
3112
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/build/builds`
3113
+ );
3114
+ url.searchParams.set("branchName", `refs/pull/${prId}/merge`);
3115
+ url.searchParams.set("queryOrder", "queueTimeDescending");
3116
+ url.searchParams.set("$top", "50");
3117
+ url.searchParams.set("api-version", "7.1");
3118
+ return url;
3119
+ }
2334
3120
  function mapPullRequest(repo, pullRequest) {
2335
3121
  return {
2336
3122
  id: pullRequest.pullRequestId,
@@ -2343,9 +3129,9 @@ function mapPullRequest(repo, pullRequest) {
2343
3129
  url: pullRequest._links?.web?.href ?? null
2344
3130
  };
2345
3131
  }
2346
- function mapPullRequestCheckName(status) {
2347
- const genre = status.context?.genre?.trim();
2348
- const name = status.context?.name?.trim();
3132
+ function mapPullRequestCheckName(status2) {
3133
+ const genre = status2.context?.genre?.trim();
3134
+ const name = status2.context?.name?.trim();
2349
3135
  if (genre && name) {
2350
3136
  return `${genre}/${name}`;
2351
3137
  }
@@ -2355,21 +3141,80 @@ function mapPullRequestCheckName(status) {
2355
3141
  if (genre) {
2356
3142
  return genre;
2357
3143
  }
2358
- return `Status #${status.id}`;
3144
+ return `Status #${status2.id}`;
3145
+ }
3146
+ function mapPullRequestCheck(status2) {
3147
+ if (status2.state === "notApplicable" || status2.state === "notSet") {
3148
+ return null;
3149
+ }
3150
+ return {
3151
+ id: status2.id,
3152
+ state: status2.state,
3153
+ name: mapPullRequestCheckName(status2),
3154
+ description: status2.description ?? null,
3155
+ targetUrl: status2.targetUrl ?? null,
3156
+ createdBy: status2.createdBy?.displayName ?? null,
3157
+ createdAt: status2.creationDate ?? null,
3158
+ updatedAt: status2.updatedDate ?? null,
3159
+ source: "status"
3160
+ };
3161
+ }
3162
+ function mapPolicyEvaluationState(status2) {
3163
+ switch (status2) {
3164
+ case "approved":
3165
+ return "succeeded";
3166
+ case "rejected":
3167
+ return "failed";
3168
+ case "running":
3169
+ case "queued":
3170
+ return "pending";
3171
+ case "notApplicable":
3172
+ case "notSet":
3173
+ case void 0:
3174
+ return null;
3175
+ default:
3176
+ return status2;
3177
+ }
3178
+ }
3179
+ function mapBuildToCheckState(build) {
3180
+ if (build.status !== "completed") {
3181
+ return "pending";
3182
+ }
3183
+ switch (build.result) {
3184
+ case "succeeded":
3185
+ case "partiallySucceeded":
3186
+ return "succeeded";
3187
+ case "failed":
3188
+ return "failed";
3189
+ case "canceled":
3190
+ return "error";
3191
+ default:
3192
+ return "pending";
3193
+ }
3194
+ }
3195
+ function mapPolicyEvaluationName(evaluation) {
3196
+ const display = evaluation.configuration?.settings?.displayName?.trim() || evaluation.configuration?.type?.displayName?.trim();
3197
+ if (display) {
3198
+ return display;
3199
+ }
3200
+ return `Policy ${evaluation.configuration?.id ?? evaluation.evaluationId ?? "?"}`;
2359
3201
  }
2360
- function mapPullRequestCheck(status) {
2361
- if (status.state === "notApplicable" || status.state === "notSet") {
3202
+ function mapPolicyEvaluationCheck(evaluation) {
3203
+ const state = mapPolicyEvaluationState(evaluation.status);
3204
+ if (state === null) {
2362
3205
  return null;
2363
3206
  }
2364
3207
  return {
2365
- id: status.id,
2366
- state: status.state,
2367
- name: mapPullRequestCheckName(status),
2368
- description: status.description ?? null,
2369
- targetUrl: status.targetUrl ?? null,
2370
- createdBy: status.createdBy?.displayName ?? null,
2371
- createdAt: status.creationDate ?? null,
2372
- updatedAt: status.updatedDate ?? null
3208
+ id: evaluation.configuration?.id ?? 0,
3209
+ state,
3210
+ name: mapPolicyEvaluationName(evaluation),
3211
+ description: null,
3212
+ targetUrl: null,
3213
+ createdBy: null,
3214
+ createdAt: null,
3215
+ updatedAt: null,
3216
+ source: "policy",
3217
+ isBlocking: evaluation.configuration?.isBlocking ?? null
2373
3218
  };
2374
3219
  }
2375
3220
  function mapComment(comment) {
@@ -2389,24 +3234,28 @@ function mapThread(thread) {
2389
3234
  if (comments.length === 0) {
2390
3235
  return null;
2391
3236
  }
3237
+ const line = thread.threadContext?.rightFileStart?.line ?? thread.threadContext?.leftFileStart?.line ?? null;
2392
3238
  return {
2393
3239
  id: thread.id,
2394
- status: thread.status,
3240
+ status: thread.status ?? "unknown",
2395
3241
  threadContext: thread.threadContext?.filePath ?? null,
3242
+ line,
2396
3243
  comments
2397
3244
  };
2398
3245
  }
2399
3246
  function toActiveCommentThread(thread) {
3247
+ const line = thread.threadContext?.rightFileStart?.line ?? thread.threadContext?.leftFileStart?.line ?? null;
2400
3248
  return {
2401
3249
  id: thread.id,
2402
- status: thread.status,
3250
+ status: thread.status ?? "unknown",
2403
3251
  threadContext: thread.threadContext?.filePath ?? null,
3252
+ line,
2404
3253
  comments: thread.comments.map(mapComment).filter((comment) => comment !== null)
2405
3254
  };
2406
3255
  }
2407
3256
  var RESOLVED_THREAD_STATUSES = /* @__PURE__ */ new Set(["fixed", "wontFix", "closed", "byDesign"]);
2408
- function isThreadResolved(status) {
2409
- return RESOLVED_THREAD_STATUSES.has(status);
3257
+ function isThreadResolved(status2) {
3258
+ return RESOLVED_THREAD_STATUSES.has(status2);
2410
3259
  }
2411
3260
  async function readJsonResponse(response) {
2412
3261
  if (!response.ok) {
@@ -2414,7 +3263,7 @@ async function readJsonResponse(response) {
2414
3263
  }
2415
3264
  return response.json();
2416
3265
  }
2417
- async function patchThreadStatus(context, repo, pat, prId, threadId, status) {
3266
+ async function patchThreadStatus(context, repo, cred, prId, threadId, status2) {
2418
3267
  const url = new URL(
2419
3268
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}/threads/${threadId}`
2420
3269
  );
@@ -2422,41 +3271,74 @@ async function patchThreadStatus(context, repo, pat, prId, threadId, status) {
2422
3271
  const response = await fetchWithErrors(url.toString(), {
2423
3272
  method: "PATCH",
2424
3273
  headers: {
2425
- ...authHeaders(pat),
3274
+ ...authHeaders(cred),
2426
3275
  "Content-Type": "application/json"
2427
3276
  },
2428
- body: JSON.stringify({ status })
3277
+ body: JSON.stringify({ status: status2 })
2429
3278
  });
2430
3279
  const data = await readJsonResponse(response);
2431
3280
  return toActiveCommentThread(data);
2432
3281
  }
2433
- async function getPullRequestById(context, repo, pat, prId) {
3282
+ async function getPullRequestById(context, repo, cred, prId) {
2434
3283
  const url = new URL(
2435
3284
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}`
2436
3285
  );
2437
3286
  url.searchParams.set("api-version", "7.1");
2438
- const response = await fetchWithErrors(url.toString(), { headers: authHeaders(pat) });
3287
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
2439
3288
  const data = await readJsonResponse(response);
2440
3289
  return mapPullRequest(repo, data);
2441
3290
  }
2442
- async function listPullRequests(context, repo, pat, sourceBranch, opts) {
3291
+ async function listPullRequests(context, repo, cred, sourceBranch, opts) {
2443
3292
  const response = await fetchWithErrors(
2444
3293
  buildPullRequestsUrl(context, repo, sourceBranch, opts).toString(),
2445
- { headers: authHeaders(pat) }
3294
+ { headers: authHeaders(cred) }
2446
3295
  );
2447
3296
  const data = await readJsonResponse(response);
2448
3297
  return data.value.map((pullRequest) => mapPullRequest(repo, pullRequest));
2449
3298
  }
2450
- async function getPullRequestChecks(context, repo, pat, prId) {
3299
+ async function getPullRequestChecks(context, repo, cred, prId) {
2451
3300
  const response = await fetchWithErrors(
2452
3301
  buildPullRequestStatusesUrl(context, repo, prId).toString(),
2453
- { headers: authHeaders(pat) }
3302
+ { headers: authHeaders(cred) }
2454
3303
  );
2455
3304
  const data = await readJsonResponse(response);
2456
3305
  return data.value.map(mapPullRequestCheck).filter((check) => check !== null);
2457
3306
  }
2458
- async function openPullRequest(context, repo, pat, sourceBranch, title, description) {
2459
- const existing = await listPullRequests(context, repo, pat, sourceBranch, {
3307
+ async function resolveProjectId(context, cred) {
3308
+ const response = await fetchWithErrors(buildProjectUrl(context).toString(), {
3309
+ headers: authHeaders(cred)
3310
+ });
3311
+ const data = await readJsonResponse(response);
3312
+ return data.id;
3313
+ }
3314
+ async function getPullRequestPolicyEvaluations(context, cred, projectId, prId) {
3315
+ const response = await fetchWithErrors(
3316
+ buildPolicyEvaluationsUrl(context, projectId, prId).toString(),
3317
+ { headers: authHeaders(cred) }
3318
+ );
3319
+ const data = await readJsonResponse(response);
3320
+ return data.value.map(mapPolicyEvaluationCheck).filter((check) => check !== null);
3321
+ }
3322
+ async function getPullRequestBuilds(context, cred, prId) {
3323
+ const response = await fetchWithErrors(buildPullRequestBuildsUrl(context, prId).toString(), {
3324
+ headers: authHeaders(cred)
3325
+ });
3326
+ const data = await readJsonResponse(response);
3327
+ return data.value.map((build) => ({
3328
+ id: build.id,
3329
+ state: mapBuildToCheckState(build),
3330
+ name: build.definition?.name ?? `Build #${build.id}`,
3331
+ description: null,
3332
+ targetUrl: build._links?.web?.href ?? null,
3333
+ createdBy: null,
3334
+ createdAt: build.queueTime ?? null,
3335
+ updatedAt: build.finishTime ?? null,
3336
+ source: "build",
3337
+ isBlocking: null
3338
+ }));
3339
+ }
3340
+ async function openPullRequest(context, repo, cred, sourceBranch, title, description) {
3341
+ const existing = await listPullRequests(context, repo, cred, sourceBranch, {
2460
3342
  status: "active",
2461
3343
  targetBranch: "develop"
2462
3344
  });
@@ -2484,7 +3366,7 @@ async function openPullRequest(context, repo, pat, sourceBranch, title, descript
2484
3366
  const response = await fetchWithErrors(url.toString(), {
2485
3367
  method: "POST",
2486
3368
  headers: {
2487
- ...authHeaders(pat),
3369
+ ...authHeaders(cred),
2488
3370
  "Content-Type": "application/json"
2489
3371
  },
2490
3372
  body: JSON.stringify(payload)
@@ -2497,15 +3379,39 @@ async function openPullRequest(context, repo, pat, sourceBranch, title, descript
2497
3379
  pullRequest: mapPullRequest(repo, data)
2498
3380
  };
2499
3381
  }
2500
- async function getPullRequestThreads(context, repo, pat, prId) {
3382
+ async function getPullRequestThreads(context, repo, cred, prId) {
2501
3383
  const url = new URL(
2502
3384
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}/threads`
2503
3385
  );
2504
3386
  url.searchParams.set("api-version", "7.1");
2505
- const response = await fetchWithErrors(url.toString(), { headers: authHeaders(pat) });
3387
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
2506
3388
  const data = await readJsonResponse(response);
2507
3389
  return data.value.map(mapThread).filter((thread) => thread !== null);
2508
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
+ }
2509
3415
 
2510
3416
  // src/commands/pr.ts
2511
3417
  function parsePositivePrNumber(raw) {
@@ -2515,6 +3421,22 @@ function parsePositivePrNumber(raw) {
2515
3421
  const n = Number.parseInt(raw, 10);
2516
3422
  return Number.isFinite(n) && n > 0 ? n : null;
2517
3423
  }
3424
+ var PR_NUMBER_HELP = "target the pull request with this numeric id, instead of the current branch's PR. When omitted, the CLI auto-detects the pull request whose source branch equals refs/heads/<current branch> in the Azure DevOps repository identified by the origin remote; if zero or more than one open PR matches, the command fails with a message naming the searched branch.";
3425
+ function configureUnwrappedHelp(command) {
3426
+ return command.configureHelp({ helpWidth: 1e3 });
3427
+ }
3428
+ function autoDetectZeroMatch(branch) {
3429
+ return `No open pull request matches branch ${branch}. Pass --pr-number to target a specific PR, or push the branch and open a pull request.`;
3430
+ }
3431
+ function autoDetectMultiMatch(branch, ids) {
3432
+ const idList = ids.map((id) => `#${id}`).join(", ");
3433
+ return `Multiple open pull requests match branch ${branch}: ${idList}. Re-run with --pr-number to choose.`;
3434
+ }
3435
+ function writeContractError(line) {
3436
+ process.stderr.write(`${line}
3437
+ `);
3438
+ process.exitCode = 1;
3439
+ }
2518
3440
  function formatBranchName(refName) {
2519
3441
  return refName.startsWith("refs/heads/") ? refName.slice("refs/heads/".length) : refName;
2520
3442
  }
@@ -2548,34 +3470,101 @@ function handlePrCommandError(err, context, mode = "read") {
2548
3470
  }
2549
3471
  writeError(error.message);
2550
3472
  }
2551
- function formatPullRequestChecks(checks) {
3473
+ function formatPullRequestChecks(checks, checksError) {
3474
+ if (checksError) {
3475
+ return [`Checks: unable to retrieve (${checksError})`];
3476
+ }
2552
3477
  if (checks.length === 0) {
2553
3478
  return ["Checks: none reported by Azure DevOps"];
2554
3479
  }
2555
3480
  const lines = ["Checks:"];
2556
3481
  for (const check of checks) {
2557
- lines.push(`- [${check.state}] ${check.name}`);
3482
+ const optionalTag = check.isBlocking === false ? " [optional]" : "";
3483
+ lines.push(`- [${check.state}] ${check.name}${optionalTag}`);
2558
3484
  if ((check.state === "failed" || check.state === "error") && check.description) {
2559
3485
  lines.push(` Detail: ${check.description}`);
2560
3486
  }
2561
3487
  }
2562
3488
  return lines;
2563
3489
  }
3490
+ function countCodeComments(threads) {
3491
+ let open = 0;
3492
+ let closed = 0;
3493
+ for (const thread of threads) {
3494
+ if (thread.threadContext === null) {
3495
+ continue;
3496
+ }
3497
+ if (isThreadResolved(thread.status)) {
3498
+ closed += 1;
3499
+ } else {
3500
+ open += 1;
3501
+ }
3502
+ }
3503
+ return { open, closed };
3504
+ }
3505
+ function formatCodeCommentCounts(counts) {
3506
+ return `Code comments: ${counts.open} open, ${counts.closed} closed`;
3507
+ }
2564
3508
  function formatPullRequestBlock(pullRequest) {
2565
3509
  return [
2566
3510
  `#${pullRequest.id} [${pullRequest.status}] ${pullRequest.title}`,
2567
3511
  `${formatBranchName(pullRequest.sourceRefName)} -> ${formatBranchName(pullRequest.targetRefName)}`,
2568
3512
  pullRequest.url ?? "\u2014",
2569
- ...formatPullRequestChecks(pullRequest.checks)
3513
+ ...formatPullRequestChecks(pullRequest.checks, pullRequest.checksError),
3514
+ formatCodeCommentCounts(pullRequest.codeCommentCounts)
2570
3515
  ].join("\n");
2571
3516
  }
2572
- function threadStatusLabel(status) {
2573
- return isThreadResolved(status) ? "resolved" : status;
3517
+ async function buildPullRequestStatusEntry(context, repo, cred, pullRequest, projectId) {
3518
+ let statusChecks = [];
3519
+ let statusOk = true;
3520
+ try {
3521
+ statusChecks = await getPullRequestChecks(context, repo, cred, pullRequest.id);
3522
+ } catch {
3523
+ statusOk = false;
3524
+ }
3525
+ let policyChecks = [];
3526
+ let policyOk = true;
3527
+ if (projectId === null) {
3528
+ policyOk = false;
3529
+ } else {
3530
+ try {
3531
+ policyChecks = await getPullRequestPolicyEvaluations(context, cred, projectId, pullRequest.id);
3532
+ } catch {
3533
+ policyOk = false;
3534
+ }
3535
+ }
3536
+ let buildChecks = [];
3537
+ let buildsOk = true;
3538
+ try {
3539
+ buildChecks = await getPullRequestBuilds(context, cred, pullRequest.id);
3540
+ } catch {
3541
+ buildsOk = false;
3542
+ }
3543
+ let codeCommentCounts;
3544
+ try {
3545
+ const threads = await getPullRequestThreads(context, repo, cred, pullRequest.id);
3546
+ codeCommentCounts = countCodeComments(threads);
3547
+ } catch {
3548
+ codeCommentCounts = { open: 0, closed: 0 };
3549
+ }
3550
+ const checks = [...statusChecks, ...policyChecks, ...buildChecks];
3551
+ const checksError = checks.length === 0 && (!statusOk || !policyOk || !buildsOk) ? "Azure DevOps request failed" : null;
3552
+ return {
3553
+ ...pullRequest,
3554
+ checks,
3555
+ codeCommentCounts,
3556
+ checksError
3557
+ };
3558
+ }
3559
+ function threadStatusLabel(status2) {
3560
+ return isThreadResolved(status2) ? "resolved" : status2;
2574
3561
  }
2575
3562
  function formatThreads(prId, title, threads) {
2576
3563
  const lines = [`Comment threads for pull request #${prId}: ${title}`];
2577
3564
  for (const thread of threads) {
2578
- lines.push("", `Thread #${thread.id} [${threadStatusLabel(thread.status)}] ${thread.threadContext ?? "(general)"}`);
3565
+ const lineSuffix = thread.line === null ? "" : `:${thread.line}`;
3566
+ const location = thread.threadContext ? `${thread.threadContext}${lineSuffix}` : "(general)";
3567
+ lines.push("", `Thread #${thread.id} [${threadStatusLabel(thread.status)}] ${location}`);
2579
3568
  for (const comment of thread.comments) {
2580
3569
  lines.push(` ${comment.author ?? "Unknown"}: ${comment.content}`);
2581
3570
  }
@@ -2587,12 +3576,12 @@ async function resolvePrCommandContext(options, resolveOpts = {}) {
2587
3576
  const context = resolveContext(options);
2588
3577
  const repo = detectRepoName();
2589
3578
  const branch = requireBranch ? getCurrentBranch() : null;
2590
- const credential = await requirePat(context.org);
3579
+ const credential = await requireAuthCredential(context.org);
2591
3580
  return {
2592
3581
  context,
2593
3582
  repo,
2594
3583
  branch,
2595
- pat: credential.pat
3584
+ pat: credential
2596
3585
  };
2597
3586
  }
2598
3587
  function createPrStatusCommand() {
@@ -2605,11 +3594,16 @@ function createPrStatusCommand() {
2605
3594
  context = resolved.context;
2606
3595
  const branch = resolved.branch;
2607
3596
  const pullRequests = await listPullRequests(resolved.context, resolved.repo, resolved.pat, branch);
3597
+ let projectId = null;
3598
+ try {
3599
+ projectId = await resolveProjectId(resolved.context, resolved.pat);
3600
+ } catch {
3601
+ projectId = null;
3602
+ }
2608
3603
  const pullRequestsWithChecks = await Promise.all(
2609
- pullRequests.map(async (pullRequest) => ({
2610
- ...pullRequest,
2611
- checks: await getPullRequestChecks(resolved.context, resolved.repo, resolved.pat, pullRequest.id)
2612
- }))
3604
+ pullRequests.map(
3605
+ async (pullRequest) => buildPullRequestStatusEntry(resolved.context, resolved.repo, resolved.pat, pullRequest, projectId)
3606
+ )
2613
3607
  );
2614
3608
  const result = { branch, repository: resolved.repo, pullRequests: pullRequestsWithChecks };
2615
3609
  if (options.json) {
@@ -2690,7 +3684,7 @@ ${result.pullRequest.url ?? "\u2014"}
2690
3684
  }
2691
3685
  function createPrCommentsCommand() {
2692
3686
  const command = new Command12("comments");
2693
- command.description("List pull request comment threads for the current branch").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--pr-number <N>", "target the pull request with this numeric id, instead of the current branch's PR").option("--hide-resolved", "hide threads whose status is resolved / won't fix / closed / by design").option("--json", "output JSON").action(async (options) => {
3687
+ configureUnwrappedHelp(command).description("List pull request comment threads for the current branch").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--pr-number <N>", PR_NUMBER_HELP).option("--hide-resolved", "hide threads whose status is resolved / won't fix / closed / by design").option("--exclude-resolved", "alias of --hide-resolved: exclude resolved / won't fix / closed / by design threads").option("--code-related-only", "show only threads anchored to a file/line; omit general discussion threads").option("--json", "output JSON").action(async (options) => {
2694
3688
  validateOrgProjectPair(options);
2695
3689
  let context;
2696
3690
  let explicitPrId = null;
@@ -2722,19 +3716,22 @@ function createPrCommentsCommand() {
2722
3716
  status: "active"
2723
3717
  });
2724
3718
  if (pullRequests.length === 0) {
2725
- writeError(`No active pull request found for branch ${resolved.branch}.`);
3719
+ writeContractError(autoDetectZeroMatch(resolved.branch));
2726
3720
  return;
2727
3721
  }
2728
3722
  if (pullRequests.length > 1) {
2729
- const ids = pullRequests.map((pr) => `#${pr.id}`).join(", ");
2730
- writeError(`Multiple active pull requests found for branch ${resolved.branch}: ${ids}. Use pr status to review them.`);
3723
+ writeContractError(autoDetectMultiMatch(resolved.branch, pullRequests.map((pr) => pr.id)));
2731
3724
  return;
2732
3725
  }
2733
3726
  pullRequest = pullRequests[0];
2734
3727
  branchLabel = resolved.branch;
2735
3728
  }
3729
+ const hideResolved = options.hideResolved === true || options.excludeResolved === true;
3730
+ const codeRelatedOnly = options.codeRelatedOnly === true;
2736
3731
  const allThreads = await getPullRequestThreads(resolved.context, resolved.repo, resolved.pat, pullRequest.id);
2737
- const threads = options.hideResolved ? allThreads.filter((thread) => !isThreadResolved(thread.status)) : allThreads;
3732
+ const threads = allThreads.filter(
3733
+ (thread) => (!hideResolved || !isThreadResolved(thread.status)) && (!codeRelatedOnly || thread.threadContext !== null)
3734
+ );
2738
3735
  const result = { branch: branchLabel, pullRequest, threads };
2739
3736
  if (options.json) {
2740
3737
  process.stdout.write(`${JSON.stringify(result, null, 2)}
@@ -2742,9 +3739,18 @@ function createPrCommentsCommand() {
2742
3739
  return;
2743
3740
  }
2744
3741
  if (threads.length === 0) {
2745
- if (options.hideResolved && allThreads.length > 0) {
2746
- process.stdout.write(`Pull request #${pullRequest.id} has no unresolved comment threads (${allThreads.length} resolved thread${allThreads.length === 1 ? "" : "s"} hidden by --hide-resolved).
2747
- `);
3742
+ if (allThreads.length > 0 && (hideResolved || codeRelatedOnly)) {
3743
+ const filters = [];
3744
+ if (codeRelatedOnly) {
3745
+ filters.push("code-related");
3746
+ }
3747
+ if (hideResolved) {
3748
+ filters.push("unresolved");
3749
+ }
3750
+ process.stdout.write(
3751
+ `Pull request #${pullRequest.id} has no ${filters.join(" ")} comment threads (filtered from ${allThreads.length} thread${allThreads.length === 1 ? "" : "s"}).
3752
+ `
3753
+ );
2748
3754
  } else {
2749
3755
  process.stdout.write(`Pull request #${pullRequest.id} has no comment threads.
2750
3756
  `);
@@ -2757,6 +3763,7 @@ function createPrCommentsCommand() {
2757
3763
  handlePrCommandError(err, context, "read");
2758
3764
  }
2759
3765
  });
3766
+ command.addCommand(createPrCommentsReplyCommand());
2760
3767
  return command;
2761
3768
  }
2762
3769
  async function resolveThreadTarget(threadIdRaw, options) {
@@ -2791,12 +3798,11 @@ async function resolveThreadTarget(threadIdRaw, options) {
2791
3798
  status: "active"
2792
3799
  });
2793
3800
  if (pullRequests.length === 0) {
2794
- writeError(`No active pull request found for branch ${resolved.branch}.`);
3801
+ writeContractError(autoDetectZeroMatch(resolved.branch));
2795
3802
  return null;
2796
3803
  }
2797
3804
  if (pullRequests.length > 1) {
2798
- const ids = pullRequests.map((pr) => `#${pr.id}`).join(", ");
2799
- writeError(`Multiple active pull requests found for branch ${resolved.branch}: ${ids}. Use pr status to review them.`);
3805
+ writeContractError(autoDetectMultiMatch(resolved.branch, pullRequests.map((pr) => pr.id)));
2800
3806
  return null;
2801
3807
  }
2802
3808
  pullRequest = pullRequests[0];
@@ -2864,18 +3870,76 @@ async function runThreadStateChange(threadIdRaw, options, direction) {
2864
3870
  }
2865
3871
  function createPrCommentResolveCommand() {
2866
3872
  const command = new Command12("comment-resolve");
2867
- command.description("Mark a pull request comment thread as resolved").argument("<threadId>", "numeric id of the thread to resolve").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--pr-number <N>", "target the pull request with this numeric id, instead of the current branch's PR").option("--json", "output JSON").action(async (threadIdRaw, options) => {
3873
+ configureUnwrappedHelp(command).description("Mark a pull request comment thread as resolved").argument("<threadId>", "numeric id of the thread to resolve").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, options) => {
2868
3874
  await runThreadStateChange(threadIdRaw, options, "resolve");
2869
3875
  });
2870
3876
  return command;
2871
3877
  }
2872
3878
  function createPrCommentReopenCommand() {
2873
3879
  const command = new Command12("comment-reopen");
2874
- command.description("Reopen (set to active) a previously resolved pull request comment thread").argument("<threadId>", "numeric id of the thread to reopen").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--pr-number <N>", "target the pull request with this numeric id, instead of the current branch's PR").option("--json", "output JSON").action(async (threadIdRaw, options) => {
3880
+ configureUnwrappedHelp(command).description("Reopen (set to active) a previously resolved pull request comment thread").argument("<threadId>", "numeric id of the thread to reopen").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, options) => {
2875
3881
  await runThreadStateChange(threadIdRaw, options, "reopen");
2876
3882
  });
2877
3883
  return command;
2878
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
+ }
2879
3943
  function createPrCommand() {
2880
3944
  const command = new Command12("pr");
2881
3945
  command.description("Manage Azure DevOps pull requests");
@@ -2884,71 +3948,984 @@ function createPrCommand() {
2884
3948
  command.addCommand(createPrCommentsCommand());
2885
3949
  command.addCommand(createPrCommentResolveCommand());
2886
3950
  command.addCommand(createPrCommentReopenCommand());
3951
+ command.addCommand(createPrCommentReplyCommand());
2887
3952
  return command;
2888
3953
  }
2889
3954
 
2890
- // src/commands/comments.ts
3955
+ // src/commands/pipeline.ts
2891
3956
  import { Command as Command13 } from "commander";
2892
- function writeError2(message) {
2893
- process.stderr.write(`Error: ${message}
2894
- `);
2895
- process.exit(1);
3957
+
3958
+ // src/services/pipeline-client.ts
3959
+ var API_VERSION = "7.1";
3960
+ function orgProjectBase(context) {
3961
+ return `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}`;
2896
3962
  }
2897
- function formatCommentHeader(comment) {
2898
- const author = comment.author ?? "Unknown";
2899
- const timestamp = comment.modifiedAt ?? comment.createdAt ?? "Unknown time";
2900
- return `Comment #${comment.id} by ${author} at ${timestamp}`;
3963
+ function withApiVersion(url) {
3964
+ url.searchParams.set("api-version", API_VERSION);
3965
+ return url;
2901
3966
  }
2902
- function formatComments(result, convertMarkdown) {
2903
- const lines = [`Comments for work item #${result.workItemId}`];
2904
- for (const comment of result.comments) {
2905
- const text = convertMarkdown ? toMarkdown(comment.text) : comment.text;
2906
- lines.push("", formatCommentHeader(comment), text);
3967
+ async function readJsonResponse2(response) {
3968
+ if (!response.ok) {
3969
+ throw new Error(`HTTP_${response.status}`);
2907
3970
  }
2908
- return lines.join("\n");
3971
+ return await response.json();
2909
3972
  }
2910
- function createCommentsListCommand() {
2911
- const command = new Command13("list");
2912
- command.description("List visible comments for a work item").argument("<id>", "work item ID").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output JSON").option("--markdown", "convert HTML comment bodies to markdown").action(async (idStr, options) => {
2913
- validateOrgProjectPair(options);
2914
- const id = parseWorkItemId(idStr);
2915
- let context;
2916
- try {
2917
- context = resolveContext(options);
2918
- const credential = await requirePat(context.org);
2919
- const result = await listWorkItemComments(context, id, credential.pat);
2920
- if (options.json) {
2921
- process.stdout.write(`${JSON.stringify(result, null, 2)}
2922
- `);
2923
- return;
2924
- }
2925
- if (result.comments.length === 0) {
2926
- process.stdout.write(`Work item #${id} has no comments.
2927
- `);
2928
- return;
3973
+ function mapPipeline(pipeline) {
3974
+ return {
3975
+ id: pipeline.id,
3976
+ name: pipeline.name,
3977
+ folder: pipeline.folder?.trim() ? pipeline.folder : null
3978
+ };
3979
+ }
3980
+ function mapRunState(state) {
3981
+ if (state === "inProgress" || state === "completed") {
3982
+ return state;
3983
+ }
3984
+ return "unknown";
3985
+ }
3986
+ function mapRunResult(result) {
3987
+ if (result === "succeeded" || result === "failed" || result === "canceled") {
3988
+ return result;
3989
+ }
3990
+ if (result === "partiallySucceeded") {
3991
+ return "failed";
3992
+ }
3993
+ return null;
3994
+ }
3995
+ function mapBuildState(status2) {
3996
+ if (status2 === "completed") return "completed";
3997
+ if (status2 === void 0 || status2 === "none") return "unknown";
3998
+ return "inProgress";
3999
+ }
4000
+ function mapBuildSummary(build) {
4001
+ return {
4002
+ id: build.id,
4003
+ name: build.buildNumber ?? null,
4004
+ state: mapBuildState(build.status),
4005
+ result: mapRunResult(build.result),
4006
+ createdDate: build.queueTime ?? build.startTime ?? null,
4007
+ finishedDate: build.finishTime ?? null,
4008
+ sourceBranch: build.sourceBranch ?? null,
4009
+ sourceCommit: build.sourceVersion ?? null
4010
+ };
4011
+ }
4012
+ function normalizeRef(branch) {
4013
+ return branch.startsWith("refs/") ? branch : `refs/heads/${branch}`;
4014
+ }
4015
+ async function getPipelineDefinitions(context, cred) {
4016
+ const url = withApiVersion(new URL(`${orgProjectBase(context)}/_apis/pipelines`));
4017
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
4018
+ const data = await readJsonResponse2(response);
4019
+ return data.value.map(mapPipeline);
4020
+ }
4021
+ var COMMIT_LOOKBACK = 200;
4022
+ async function getPipelineRuns(context, cred, query) {
4023
+ const url = withApiVersion(new URL(`${orgProjectBase(context)}/_apis/build/builds`));
4024
+ if (query.definitionId !== void 0) {
4025
+ url.searchParams.set("definitions", String(query.definitionId));
4026
+ }
4027
+ if (query.prNumber !== void 0) {
4028
+ url.searchParams.set("branchName", `refs/pull/${query.prNumber}/merge`);
4029
+ } else if (query.branch) {
4030
+ url.searchParams.set("branchName", normalizeRef(query.branch));
4031
+ }
4032
+ url.searchParams.set("queryOrder", "queueTimeDescending");
4033
+ url.searchParams.set("$top", String(query.commit ? COMMIT_LOOKBACK : query.top));
4034
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
4035
+ const data = await readJsonResponse2(response);
4036
+ let runs = data.value.map(mapBuildSummary);
4037
+ if (query.commit) {
4038
+ const needle = query.commit.toLowerCase();
4039
+ runs = runs.filter((run) => run.sourceCommit?.toLowerCase().startsWith(needle));
4040
+ }
4041
+ return runs.slice(0, query.top);
4042
+ }
4043
+ async function runPipeline(context, cred, pipelineId, opts) {
4044
+ const url = withApiVersion(
4045
+ new URL(`${orgProjectBase(context)}/_apis/pipelines/${pipelineId}/runs`)
4046
+ );
4047
+ const body = {};
4048
+ if (opts.branch) {
4049
+ const refName = opts.branch.startsWith("refs/") ? opts.branch : `refs/heads/${opts.branch}`;
4050
+ body.resources = { repositories: { self: { refName } } };
4051
+ }
4052
+ if (opts.parameters && Object.keys(opts.parameters).length > 0) {
4053
+ body.templateParameters = opts.parameters;
4054
+ }
4055
+ const response = await fetchWithErrors(url.toString(), {
4056
+ method: "POST",
4057
+ headers: { ...authHeaders(cred), "Content-Type": "application/json" },
4058
+ body: JSON.stringify(body)
4059
+ });
4060
+ const data = await readJsonResponse2(response);
4061
+ return {
4062
+ id: data.id,
4063
+ state: mapRunState(data.state),
4064
+ webUrl: data._links?.web?.href ?? null
4065
+ };
4066
+ }
4067
+ function buildUrl(context, buildId) {
4068
+ return withApiVersion(new URL(`${orgProjectBase(context)}/_apis/build/builds/${buildId}`));
4069
+ }
4070
+ async function getBuild(context, cred, buildId) {
4071
+ const response = await fetchWithErrors(buildUrl(context, buildId).toString(), {
4072
+ headers: authHeaders(cred)
4073
+ });
4074
+ return readJsonResponse2(response);
4075
+ }
4076
+ async function getBuildStatus(context, cred, buildId) {
4077
+ const build = await getBuild(context, cred, buildId);
4078
+ return { state: mapBuildState(build.status), result: mapRunResult(build.result) };
4079
+ }
4080
+ async function getBuildTimeline(context, cred, buildId) {
4081
+ const url = withApiVersion(
4082
+ new URL(`${orgProjectBase(context)}/_apis/build/builds/${buildId}/timeline`)
4083
+ );
4084
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
4085
+ const data = await readJsonResponse2(response);
4086
+ const records = data.records ?? [];
4087
+ const errors = [];
4088
+ const stages = [];
4089
+ const jobs = [];
4090
+ const logSteps = /* @__PURE__ */ new Map();
4091
+ for (const record of records) {
4092
+ for (const issue of record.issues ?? []) {
4093
+ if (issue.type === "error" && issue.message) {
4094
+ errors.push({ message: issue.message, source: record.name ?? null });
2929
4095
  }
2930
- process.stdout.write(`${formatComments(result, options.markdown === true)}
2931
- `);
2932
- } catch (err) {
2933
- handleCommandError(err, id, context, "read");
2934
4096
  }
4097
+ if (record.name && record.log?.id !== void 0) {
4098
+ logSteps.set(record.log.id, record.name);
4099
+ }
4100
+ if (!record.name) continue;
4101
+ const status2 = {
4102
+ name: record.name,
4103
+ state: record.state ?? "unknown",
4104
+ result: record.result ?? null
4105
+ };
4106
+ if (record.type === "Stage") {
4107
+ stages.push(status2);
4108
+ } else if (record.type === "Job") {
4109
+ jobs.push({ startTime: record.startTime, status: status2 });
4110
+ }
4111
+ }
4112
+ jobs.sort((a, b) => {
4113
+ if (a.startTime === b.startTime) return 0;
4114
+ if (a.startTime === void 0) return 1;
4115
+ if (b.startTime === void 0) return -1;
4116
+ return a.startTime < b.startTime ? -1 : 1;
2935
4117
  });
2936
- return command;
4118
+ return { errors, stages, jobs: jobs.map((j) => j.status), logSteps };
4119
+ }
4120
+ async function listTestRuns(context, cred, buildId) {
4121
+ const url = withApiVersion(new URL(`${orgProjectBase(context)}/_apis/test/runs`));
4122
+ url.searchParams.set("buildUri", `vstfs:///Build/Build/${buildId}`);
4123
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
4124
+ const data = await readJsonResponse2(response);
4125
+ return data.value;
4126
+ }
4127
+ async function getTestSummary(context, cred, buildId) {
4128
+ const testRuns = await listTestRuns(context, cred, buildId);
4129
+ let total = 0;
4130
+ let failed = 0;
4131
+ for (const run of testRuns) {
4132
+ const runTotal = run.totalTests ?? 0;
4133
+ total += runTotal;
4134
+ const passedOrSkipped = (run.passedTests ?? 0) + (run.notApplicableTests ?? 0) + (run.incompleteTests ?? 0);
4135
+ failed += Math.max(0, runTotal - passedOrSkipped);
4136
+ }
4137
+ if (total === 0) {
4138
+ return { present: false, total: 0, failed: 0, failedTests: [] };
4139
+ }
4140
+ return { present: true, total, failed, failedTests: [] };
4141
+ }
4142
+ var MAX_FAILED_TESTS = 50;
4143
+ async function getFailedTests(context, cred, buildId) {
4144
+ const testRuns = await listTestRuns(context, cred, buildId);
4145
+ const failed = [];
4146
+ for (const testRun of testRuns) {
4147
+ if (failed.length >= MAX_FAILED_TESTS) break;
4148
+ const resultsUrl = withApiVersion(
4149
+ new URL(`${orgProjectBase(context)}/_apis/test/runs/${testRun.id}/results`)
4150
+ );
4151
+ resultsUrl.searchParams.set("outcomes", "Failed");
4152
+ resultsUrl.searchParams.set("$top", String(MAX_FAILED_TESTS - failed.length));
4153
+ const resultsResponse = await fetchWithErrors(resultsUrl.toString(), {
4154
+ headers: authHeaders(cred)
4155
+ });
4156
+ const resultsData = await readJsonResponse2(resultsResponse);
4157
+ for (const result of resultsData.value) {
4158
+ failed.push({
4159
+ name: result.testCaseTitle ?? result.automatedTestName ?? "(unnamed test)",
4160
+ errorMessage: result.errorMessage ?? null
4161
+ });
4162
+ }
4163
+ }
4164
+ return failed;
4165
+ }
4166
+ function secondsBetween(start, finish) {
4167
+ if (!start || !finish) return null;
4168
+ const ms = Date.parse(finish) - Date.parse(start);
4169
+ return Number.isFinite(ms) ? Math.round(ms / 1e3) : null;
4170
+ }
4171
+ async function getRunDetail(context, cred, buildId) {
4172
+ const build = await getBuild(context, cred, buildId);
4173
+ let errors = [];
4174
+ let stages = [];
4175
+ let jobs = [];
4176
+ let errorsAvailable = true;
4177
+ try {
4178
+ const timeline = await getBuildTimeline(context, cred, buildId);
4179
+ errors = timeline.errors;
4180
+ stages = timeline.stages;
4181
+ jobs = timeline.jobs;
4182
+ } catch {
4183
+ errorsAvailable = false;
4184
+ }
4185
+ let tests = { present: false, total: 0, failed: 0, failedTests: [] };
4186
+ let testsAvailable = true;
4187
+ try {
4188
+ tests = await getTestSummary(context, cred, buildId);
4189
+ if (tests.failed > 0) {
4190
+ try {
4191
+ tests = { ...tests, failedTests: await getFailedTests(context, cred, buildId) };
4192
+ } catch {
4193
+ }
4194
+ }
4195
+ } catch {
4196
+ testsAvailable = false;
4197
+ }
4198
+ return {
4199
+ id: build.id,
4200
+ name: build.buildNumber ?? null,
4201
+ state: mapBuildState(build.status),
4202
+ result: mapRunResult(build.result),
4203
+ // createdDate is the queue time, matching the run-list mapping.
4204
+ createdDate: build.queueTime ?? build.startTime ?? null,
4205
+ startedDate: build.startTime ?? null,
4206
+ finishedDate: build.finishTime ?? null,
4207
+ durationSeconds: secondsBetween(build.startTime, build.finishTime),
4208
+ reason: build.reason ?? null,
4209
+ requestedFor: build.requestedFor?.displayName ?? null,
4210
+ sourceBranch: build.sourceBranch ?? null,
4211
+ sourceCommit: build.sourceVersion ?? null,
4212
+ webUrl: build._links?.web?.href ?? null,
4213
+ errors,
4214
+ errorsAvailable,
4215
+ stages,
4216
+ jobs,
4217
+ tests,
4218
+ testsAvailable
4219
+ };
4220
+ }
4221
+ async function getRunLogs(context, cred, buildId) {
4222
+ const url = withApiVersion(
4223
+ new URL(`${orgProjectBase(context)}/_apis/build/builds/${buildId}/logs`)
4224
+ );
4225
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
4226
+ const data = await readJsonResponse2(response);
4227
+ let logSteps = /* @__PURE__ */ new Map();
4228
+ try {
4229
+ logSteps = (await getBuildTimeline(context, cred, buildId)).logSteps;
4230
+ } catch {
4231
+ }
4232
+ return data.value.map((log) => ({
4233
+ id: log.id,
4234
+ createdOn: log.createdOn ?? null,
4235
+ lineCount: log.lineCount ?? null,
4236
+ step: logSteps.get(log.id) ?? null
4237
+ }));
4238
+ }
4239
+ async function getRunLog(context, cred, buildId, logId) {
4240
+ const url = withApiVersion(
4241
+ new URL(`${orgProjectBase(context)}/_apis/build/builds/${buildId}/logs/${logId}`)
4242
+ );
4243
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
4244
+ if (!response.ok) {
4245
+ throw new Error(`HTTP_${response.status}`);
4246
+ }
4247
+ return response.text();
4248
+ }
4249
+
4250
+ // src/commands/pipeline.ts
4251
+ var EXIT_FAILED = 1;
4252
+ var EXIT_CANCELED = 2;
4253
+ var EXIT_TIMEOUT = 124;
4254
+ function writeError2(message) {
4255
+ process.stderr.write(`Error: ${message}
4256
+ `);
4257
+ process.exitCode = 1;
4258
+ }
4259
+ function handlePipelineError(err, context) {
4260
+ const error = err instanceof Error ? err : new Error(String(err));
4261
+ if (error.message === "AUTH_FAILED") {
4262
+ writeError2('Authentication failed. Check that your credential is valid and has the "Build (Read)" scope.');
4263
+ return;
4264
+ }
4265
+ if (error.message === "PERMISSION_DENIED") {
4266
+ writeError2(`Access denied. Your credential may lack pipeline permissions for project "${context?.project}".`);
4267
+ return;
4268
+ }
4269
+ if (error.message === "NETWORK_ERROR") {
4270
+ writeError2("Could not connect to Azure DevOps. Check your network connection.");
4271
+ return;
4272
+ }
4273
+ if (error.message.startsWith("NOT_FOUND")) {
4274
+ writeError2(`Resource not found in ${context?.org}/${context?.project}.`);
4275
+ return;
4276
+ }
4277
+ if (error.message.startsWith("HTTP_")) {
4278
+ writeError2(`Azure DevOps request failed with ${error.message}.`);
4279
+ return;
4280
+ }
4281
+ writeError2(error.message);
4282
+ }
4283
+ function parsePositiveId(raw) {
4284
+ if (!/^\d+$/.test(raw)) return null;
4285
+ const n = Number.parseInt(raw, 10);
4286
+ return Number.isFinite(n) && n > 0 ? n : null;
4287
+ }
4288
+ function parseOptionalCount(value, flag) {
4289
+ if (value === void 0) return void 0;
4290
+ const parsed = parsePositiveId(value);
4291
+ if (parsed === null) {
4292
+ writeError2(`Invalid ${flag} "${value}"; expected a positive integer.`);
4293
+ return null;
4294
+ }
4295
+ return parsed;
4296
+ }
4297
+ function formatBranchName2(refName) {
4298
+ if (!refName) return "\u2014";
4299
+ return refName.startsWith("refs/heads/") ? refName.slice("refs/heads/".length) : refName;
4300
+ }
4301
+ async function resolvePipelineContext(options) {
4302
+ const context = resolveContext(options);
4303
+ const cred = await requireAuthCredential(context.org);
4304
+ return { context, cred };
4305
+ }
4306
+ function sleep(ms) {
4307
+ return new Promise((resolve2) => {
4308
+ setTimeout(resolve2, ms);
4309
+ });
4310
+ }
4311
+ function formatTable(rows, rightAlign = /* @__PURE__ */ new Set()) {
4312
+ const widths = [];
4313
+ for (const row of rows) {
4314
+ row.forEach((cell, i) => {
4315
+ widths[i] = Math.max(widths[i] ?? 0, cell.length);
4316
+ });
4317
+ }
4318
+ return rows.map(
4319
+ (row) => row.map((cell, i) => rightAlign.has(i) ? cell.padStart(widths[i]) : cell.padEnd(widths[i])).join(" ").trimEnd()
4320
+ ).join("\n");
4321
+ }
4322
+ function createPipelineListCommand() {
4323
+ const command = new Command13("list");
4324
+ command.description("List Azure DevOps pipeline definitions").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--filter <name>", "filter definitions by name (case-insensitive substring)").option("--json", "output JSON").action(async (options) => {
4325
+ validateOrgProjectPair(options);
4326
+ let context;
4327
+ try {
4328
+ const resolved = await resolvePipelineContext(options);
4329
+ context = resolved.context;
4330
+ let definitions = await getPipelineDefinitions(resolved.context, resolved.cred);
4331
+ if (options.filter) {
4332
+ const needle = options.filter.toLowerCase();
4333
+ definitions = definitions.filter((d) => d.name.toLowerCase().includes(needle));
4334
+ }
4335
+ if (options.json) {
4336
+ process.stdout.write(`${JSON.stringify(definitions, null, 2)}
4337
+ `);
4338
+ return;
4339
+ }
4340
+ if (definitions.length === 0) {
4341
+ process.stdout.write("No pipelines found.\n");
4342
+ return;
4343
+ }
4344
+ const hasFolder = definitions.some((d) => d.folder);
4345
+ const rows = definitions.map(
4346
+ (d) => hasFolder ? [String(d.id), d.name, d.folder ?? ""] : [String(d.id), d.name]
4347
+ );
4348
+ process.stdout.write(`${formatTable(rows, /* @__PURE__ */ new Set([0]))}
4349
+ `);
4350
+ } catch (err) {
4351
+ handlePipelineError(err, context);
4352
+ }
4353
+ });
4354
+ return command;
4355
+ }
4356
+ function runRow(run) {
4357
+ const status2 = run.result ? `${run.state}/${run.result}` : run.state;
4358
+ return [
4359
+ String(run.id),
4360
+ `[${status2}]`,
4361
+ run.createdDate ?? "\u2014",
4362
+ formatBranchName2(run.sourceBranch),
4363
+ run.sourceCommit ? run.sourceCommit.slice(0, 8) : "\u2014"
4364
+ ];
4365
+ }
4366
+ var COMMIT_SHA_PATTERN = /^[0-9a-f]{6,40}$/i;
4367
+ function parseGetRunsInputs(defIdRaw, options) {
4368
+ let defId;
4369
+ if (defIdRaw !== void 0) {
4370
+ const parsed = parsePositiveId(defIdRaw);
4371
+ if (parsed === null) {
4372
+ writeError2(`Invalid definition id "${defIdRaw}"; expected a positive integer.`);
4373
+ return null;
4374
+ }
4375
+ defId = parsed;
4376
+ } else if (options.commit === void 0 && options.pr === void 0) {
4377
+ writeError2("Definition id is required unless --commit or --pr is given.");
4378
+ return null;
4379
+ }
4380
+ const limit = parseOptionalCount(options.limit, "--limit");
4381
+ if (limit === null) return null;
4382
+ const prNumber = parseOptionalCount(options.pr, "--pr");
4383
+ if (prNumber === null) return null;
4384
+ if (options.commit !== void 0 && !COMMIT_SHA_PATTERN.test(options.commit)) {
4385
+ writeError2(`Invalid --commit "${options.commit}"; expected 6-40 hex characters.`);
4386
+ return null;
4387
+ }
4388
+ if (options.branch !== void 0 && prNumber !== void 0) {
4389
+ writeError2("Use either --branch or --pr, not both.");
4390
+ return null;
4391
+ }
4392
+ return { defId, limit: limit ?? 10, prNumber };
4393
+ }
4394
+ function createPipelineGetRunsCommand() {
4395
+ const command = new Command13("get-runs");
4396
+ command.description("List recent runs for a pipeline definition (newest first)").argument("[def_id]", "pipeline definition id (optional with --commit or --pr)").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--limit <n>", "maximum number of runs to show (default 10)").option("--branch <branch>", "only show runs for this source branch").option("--commit <sha>", "only show runs that built this commit (full or abbreviated SHA)").option("--pr <number>", "only show runs for this pull request").option("--json", "output JSON").action(async (defIdRaw, options) => {
4397
+ validateOrgProjectPair(options);
4398
+ const inputs = parseGetRunsInputs(defIdRaw, options);
4399
+ if (inputs === null) {
4400
+ return;
4401
+ }
4402
+ let context;
4403
+ try {
4404
+ const resolved = await resolvePipelineContext(options);
4405
+ context = resolved.context;
4406
+ const runs = await getPipelineRuns(resolved.context, resolved.cred, {
4407
+ definitionId: inputs.defId,
4408
+ branch: options.branch,
4409
+ prNumber: inputs.prNumber,
4410
+ commit: options.commit,
4411
+ top: inputs.limit
4412
+ });
4413
+ if (options.json) {
4414
+ process.stdout.write(`${JSON.stringify(runs, null, 2)}
4415
+ `);
4416
+ return;
4417
+ }
4418
+ if (runs.length === 0) {
4419
+ process.stdout.write(
4420
+ inputs.defId === void 0 ? "No runs found matching the filters.\n" : `No runs found for pipeline ${inputs.defId}.
4421
+ `
4422
+ );
4423
+ return;
4424
+ }
4425
+ process.stdout.write(`${formatTable(runs.map(runRow), /* @__PURE__ */ new Set([0]))}
4426
+ `);
4427
+ } catch (err) {
4428
+ handlePipelineError(err, context);
4429
+ }
4430
+ });
4431
+ return command;
4432
+ }
4433
+ function applyWaitExitCode(result) {
4434
+ if (result.timedOut) {
4435
+ process.exitCode = EXIT_TIMEOUT;
4436
+ return;
4437
+ }
4438
+ switch (result.result) {
4439
+ case "succeeded":
4440
+ return;
4441
+ case "canceled":
4442
+ process.exitCode = EXIT_CANCELED;
4443
+ return;
4444
+ case "failed":
4445
+ default:
4446
+ process.exitCode = EXIT_FAILED;
4447
+ }
4448
+ }
4449
+ function createPipelineWaitCommand() {
4450
+ const command = new Command13("wait");
4451
+ command.description("Wait for a pipeline run to finish; exit code reflects the result (0 success, non-zero otherwise)").argument("<run_id>", "pipeline run id").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--timeout <seconds>", "maximum seconds to wait (default 1800)").option("--poll-interval <seconds>", "seconds between status checks (default 5)").option("--json", "output JSON").action(async (runIdRaw, options) => {
4452
+ validateOrgProjectPair(options);
4453
+ const runId = parsePositiveId(runIdRaw);
4454
+ if (runId === null) {
4455
+ writeError2(`Invalid run id "${runIdRaw}"; expected a positive integer.`);
4456
+ return;
4457
+ }
4458
+ const timeoutSec = options.timeout === void 0 ? 1800 : Number(options.timeout);
4459
+ const pollSec = options.pollInterval === void 0 ? 5 : Number(options.pollInterval);
4460
+ if (!Number.isFinite(timeoutSec) || timeoutSec < 0) {
4461
+ writeError2(`Invalid --timeout "${options.timeout}"; expected a non-negative number.`);
4462
+ return;
4463
+ }
4464
+ if (!Number.isFinite(pollSec) || pollSec <= 0) {
4465
+ writeError2(`Invalid --poll-interval "${options.pollInterval}"; expected a positive number.`);
4466
+ return;
4467
+ }
4468
+ let context;
4469
+ try {
4470
+ const resolved = await resolvePipelineContext(options);
4471
+ context = resolved.context;
4472
+ const deadline = Date.now() + timeoutSec * 1e3;
4473
+ let waitResult = null;
4474
+ for (; ; ) {
4475
+ const status2 = await getBuildStatus(resolved.context, resolved.cred, runId);
4476
+ if (status2.state === "completed") {
4477
+ waitResult = { id: runId, state: status2.state, result: status2.result, timedOut: false };
4478
+ break;
4479
+ }
4480
+ if (Date.now() >= deadline) {
4481
+ waitResult = { id: runId, state: status2.state, result: status2.result, timedOut: true };
4482
+ break;
4483
+ }
4484
+ await sleep(pollSec * 1e3);
4485
+ }
4486
+ applyWaitExitCode(waitResult);
4487
+ if (options.json) {
4488
+ process.stdout.write(`${JSON.stringify(waitResult, null, 2)}
4489
+ `);
4490
+ return;
4491
+ }
4492
+ if (waitResult.timedOut) {
4493
+ process.stdout.write(`Run ${runId} did not finish within ${timeoutSec}s (still ${waitResult.state}).
4494
+ `);
4495
+ } else {
4496
+ process.stdout.write(`Run ${runId} finished: ${waitResult.result ?? waitResult.state}.
4497
+ `);
4498
+ }
4499
+ } catch (err) {
4500
+ handlePipelineError(err, context);
4501
+ }
4502
+ });
4503
+ return command;
4504
+ }
4505
+ function timelineRows(items, available) {
4506
+ if (!available) {
4507
+ return [" unavailable"];
4508
+ }
4509
+ if (items.length === 0) {
4510
+ return [" (none)"];
4511
+ }
4512
+ return items.map((item) => ` - ${item.name} [${item.result ?? item.state}]`);
4513
+ }
4514
+ function formatDuration(totalSeconds) {
4515
+ const h = Math.floor(totalSeconds / 3600);
4516
+ const m = Math.floor(totalSeconds % 3600 / 60);
4517
+ const s = totalSeconds % 60;
4518
+ if (h > 0) return `${h}h${m}m${s}s`;
4519
+ if (m > 0) return `${m}m${s}s`;
4520
+ return `${s}s`;
4521
+ }
4522
+ function errorRows(detail) {
4523
+ if (!detail.errorsAvailable) {
4524
+ return [" unavailable"];
4525
+ }
4526
+ if (detail.errors.length === 0) {
4527
+ return [" (none)"];
4528
+ }
4529
+ return detail.errors.map((error) => {
4530
+ const source = error.source ? `[${error.source}] ` : "";
4531
+ return ` - ${source}${error.message}`;
4532
+ });
4533
+ }
4534
+ function failedTestRow(test) {
4535
+ if (!test.errorMessage) {
4536
+ return ` - ${test.name}`;
4537
+ }
4538
+ const firstLine = test.errorMessage.split("\n", 1)[0].trim();
4539
+ return ` - ${test.name}: ${firstLine}`;
4540
+ }
4541
+ function testRows(detail) {
4542
+ if (!detail.testsAvailable) {
4543
+ return [" unavailable"];
4544
+ }
4545
+ if (detail.tests.present) {
4546
+ return [
4547
+ ` ${detail.tests.failed} failing of ${detail.tests.total}`,
4548
+ ...detail.tests.failedTests.map(failedTestRow)
4549
+ ];
4550
+ }
4551
+ return [" no tests present"];
4552
+ }
4553
+ function formatRunDetail(detail) {
4554
+ const status2 = detail.result ? `${detail.state}/${detail.result}` : detail.state;
4555
+ const name = detail.name ? ` ${detail.name}` : "";
4556
+ const duration = detail.durationSeconds == null ? "\u2014" : formatDuration(detail.durationSeconds);
4557
+ return [
4558
+ `Run #${detail.id} [${status2}]${name}`,
4559
+ `Queued: ${detail.createdDate ?? "\u2014"} Started: ${detail.startedDate ?? "\u2014"} Finished: ${detail.finishedDate ?? "\u2014"}`,
4560
+ `Duration: ${duration} Reason: ${detail.reason ?? "\u2014"} Requested for: ${detail.requestedFor ?? "\u2014"}`,
4561
+ `Branch: ${formatBranchName2(detail.sourceBranch)} Commit: ${detail.sourceCommit ?? "unavailable"}`,
4562
+ ...detail.webUrl ? [`Link: ${detail.webUrl}`] : [],
4563
+ "",
4564
+ "Stages:",
4565
+ ...timelineRows(detail.stages, detail.errorsAvailable),
4566
+ "",
4567
+ "Jobs:",
4568
+ ...timelineRows(detail.jobs, detail.errorsAvailable),
4569
+ "",
4570
+ "Errors:",
4571
+ ...errorRows(detail),
4572
+ "",
4573
+ "Tests:",
4574
+ ...testRows(detail)
4575
+ ].join("\n");
4576
+ }
4577
+ function createPipelineGetRunDetailCommand() {
4578
+ const command = new Command13("get-run-detail");
4579
+ command.description("Show a detailed summary of a single pipeline run (errors, failing tests, stages)").argument("<run_id>", "pipeline run id").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output JSON").action(async (runIdRaw, options) => {
4580
+ validateOrgProjectPair(options);
4581
+ const runId = parsePositiveId(runIdRaw);
4582
+ if (runId === null) {
4583
+ writeError2(`Invalid run id "${runIdRaw}"; expected a positive integer.`);
4584
+ return;
4585
+ }
4586
+ let context;
4587
+ try {
4588
+ const resolved = await resolvePipelineContext(options);
4589
+ context = resolved.context;
4590
+ const detail = await getRunDetail(resolved.context, resolved.cred, runId);
4591
+ if (options.json) {
4592
+ process.stdout.write(`${JSON.stringify(detail, null, 2)}
4593
+ `);
4594
+ return;
4595
+ }
4596
+ process.stdout.write(`${formatRunDetail(detail)}
4597
+ `);
4598
+ } catch (err) {
4599
+ handlePipelineError(err, context);
4600
+ }
4601
+ });
4602
+ return command;
4603
+ }
4604
+ function grepWithContext(lines, grep, context) {
4605
+ const include = /* @__PURE__ */ new Set();
4606
+ lines.forEach((line, i) => {
4607
+ if (grep.test(line)) {
4608
+ for (let j = Math.max(0, i - context); j <= Math.min(lines.length - 1, i + context); j++) {
4609
+ include.add(j);
4610
+ }
4611
+ }
4612
+ });
4613
+ const selected = [];
4614
+ let prev = -1;
4615
+ for (const i of [...include].sort((a, b) => a - b)) {
4616
+ if (selected.length > 0 && i > prev + 1) {
4617
+ selected.push("--");
4618
+ }
4619
+ selected.push(lines[i]);
4620
+ prev = i;
4621
+ }
4622
+ return selected;
4623
+ }
4624
+ function filterLogLines(content, grep, tail, context) {
4625
+ let lines = content.split("\n");
4626
+ if (lines.at(-1) === "") {
4627
+ lines.pop();
4628
+ }
4629
+ if (grep) {
4630
+ lines = context > 0 ? grepWithContext(lines, grep, context) : lines.filter((line) => grep.test(line));
4631
+ }
4632
+ if (tail !== void 0 && lines.length > tail) {
4633
+ lines = lines.slice(-tail);
4634
+ }
4635
+ return lines;
4636
+ }
4637
+ function parseLogFilters(options) {
4638
+ if (options.logId !== void 0 && options.step !== void 0) {
4639
+ writeError2("Use either --log-id or --step, not both.");
4640
+ return null;
4641
+ }
4642
+ const selectsSingleLog = options.logId !== void 0 || options.step !== void 0;
4643
+ const slices = options.tail !== void 0 || options.grep !== void 0 || options.context !== void 0;
4644
+ if (slices && !selectsSingleLog) {
4645
+ writeError2("--tail, --grep, and --context require --log-id or --step.");
4646
+ return null;
4647
+ }
4648
+ if (options.context !== void 0 && options.grep === void 0) {
4649
+ writeError2("--context requires --grep.");
4650
+ return null;
4651
+ }
4652
+ const tail = parseOptionalCount(options.tail, "--tail");
4653
+ if (tail === null) return null;
4654
+ const contextLines = parseOptionalCount(options.context, "--context");
4655
+ if (contextLines === null) return null;
4656
+ let grep;
4657
+ if (options.grep !== void 0) {
4658
+ try {
4659
+ grep = new RegExp(options.grep);
4660
+ } catch {
4661
+ writeError2(`Invalid --grep "${options.grep}"; expected a valid regular expression.`);
4662
+ return null;
4663
+ }
4664
+ }
4665
+ return { tail, contextLines: contextLines ?? 0, grep };
4666
+ }
4667
+ function chooseStepLog(logs, step, runId) {
4668
+ const needle = step.toLowerCase();
4669
+ const matches = logs.filter((l) => l.step?.toLowerCase().includes(needle));
4670
+ const exact = matches.filter((l) => l.step?.toLowerCase() === needle);
4671
+ const chosen = exact.length === 1 ? exact : matches;
4672
+ if (chosen.length === 0) {
4673
+ writeError2(`No log matches step "${step}" in run ${runId}.`);
4674
+ return null;
4675
+ }
4676
+ if (chosen.length > 1) {
4677
+ const candidates = chosen.map((l) => `${l.id} (${l.step})`).join(", ");
4678
+ writeError2(`Step "${step}" matches multiple logs: ${candidates}. Be more specific or use --log-id.`);
4679
+ return null;
4680
+ }
4681
+ return chosen[0].id;
4682
+ }
4683
+ async function resolveRequestedLogId(resolved, runId, options) {
4684
+ if (options.logId !== void 0) {
4685
+ const parsed = parsePositiveId(options.logId);
4686
+ if (parsed === null) {
4687
+ writeError2(`Invalid --log-id "${options.logId}"; expected a positive integer.`);
4688
+ return null;
4689
+ }
4690
+ return parsed;
4691
+ }
4692
+ if (options.step === void 0) {
4693
+ return void 0;
4694
+ }
4695
+ const allLogs = await getRunLogs(resolved.context, resolved.cred, runId);
4696
+ return chooseStepLog(allLogs, options.step, runId);
4697
+ }
4698
+ function printSingleLog(content, filters) {
4699
+ if (filters.grep !== void 0 || filters.tail !== void 0) {
4700
+ const lines = filterLogLines(content, filters.grep, filters.tail, filters.contextLines);
4701
+ if (lines.length > 0) {
4702
+ process.stdout.write(`${lines.join("\n")}
4703
+ `);
4704
+ }
4705
+ return;
4706
+ }
4707
+ process.stdout.write(content.endsWith("\n") ? content : `${content}
4708
+ `);
4709
+ }
4710
+ function createPipelineLogsCommand() {
4711
+ const command = new Command13("logs");
4712
+ command.description("List a pipeline run's logs, or print a specific log with --log-id").argument("<run_id>", "pipeline run id").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--log-id <id>", "print the content of this log id").option("--step <name>", "print the log of the step/job matching this name (case-insensitive substring)").option("--tail <n>", "with --log-id/--step, print only the last N lines").option("--grep <pattern>", "with --log-id/--step, print only lines matching this regular expression").option("--context <n>", "with --grep, also print N lines around each match (grep -C)").option("--json", "output JSON").action(async (runIdRaw, options) => {
4713
+ validateOrgProjectPair(options);
4714
+ const runId = parsePositiveId(runIdRaw);
4715
+ if (runId === null) {
4716
+ writeError2(`Invalid run id "${runIdRaw}"; expected a positive integer.`);
4717
+ return;
4718
+ }
4719
+ const filters = parseLogFilters(options);
4720
+ if (filters === null) {
4721
+ return;
4722
+ }
4723
+ let context;
4724
+ try {
4725
+ const resolved = await resolvePipelineContext(options);
4726
+ context = resolved.context;
4727
+ const logId = await resolveRequestedLogId(resolved, runId, options);
4728
+ if (logId === null) {
4729
+ return;
4730
+ }
4731
+ if (logId !== void 0) {
4732
+ const content = await getRunLog(resolved.context, resolved.cred, runId, logId);
4733
+ printSingleLog(content, filters);
4734
+ return;
4735
+ }
4736
+ const logs = await getRunLogs(resolved.context, resolved.cred, runId);
4737
+ if (options.json) {
4738
+ process.stdout.write(`${JSON.stringify(logs, null, 2)}
4739
+ `);
4740
+ return;
4741
+ }
4742
+ if (logs.length === 0) {
4743
+ process.stdout.write(`No logs found for run ${runId}.
4744
+ `);
4745
+ return;
4746
+ }
4747
+ const rows = logs.map((l) => [
4748
+ String(l.id),
4749
+ l.createdOn ?? "\u2014",
4750
+ l.lineCount == null ? "" : `${l.lineCount} lines`,
4751
+ l.step ?? ""
4752
+ ]);
4753
+ process.stdout.write(`${formatTable(rows, /* @__PURE__ */ new Set([0]))}
4754
+ `);
4755
+ } catch (err) {
4756
+ handlePipelineError(err, context);
4757
+ }
4758
+ });
4759
+ return command;
4760
+ }
4761
+ function createPipelineTestsCommand() {
4762
+ const command = new Command13("tests");
4763
+ command.description("Show a run's test results: summary plus failing tests by name (no log grepping needed)").argument("<run_id>", "pipeline run id").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--failed", "list only the failing tests").option("--json", "output JSON").action(async (runIdRaw, options) => {
4764
+ validateOrgProjectPair(options);
4765
+ const runId = parsePositiveId(runIdRaw);
4766
+ if (runId === null) {
4767
+ writeError2(`Invalid run id "${runIdRaw}"; expected a positive integer.`);
4768
+ return;
4769
+ }
4770
+ let context;
4771
+ try {
4772
+ const resolved = await resolvePipelineContext(options);
4773
+ context = resolved.context;
4774
+ const summary = await getTestSummary(resolved.context, resolved.cred, runId);
4775
+ const failedTests = summary.failed > 0 ? await getFailedTests(resolved.context, resolved.cred, runId) : [];
4776
+ if (options.json) {
4777
+ process.stdout.write(`${JSON.stringify({ ...summary, failedTests }, null, 2)}
4778
+ `);
4779
+ return;
4780
+ }
4781
+ if (!summary.present) {
4782
+ process.stdout.write(`No test results published for run ${runId}.
4783
+ `);
4784
+ return;
4785
+ }
4786
+ if (!options.failed) {
4787
+ process.stdout.write(`Run #${runId}: ${summary.failed} failing of ${summary.total} tests
4788
+ `);
4789
+ }
4790
+ if (failedTests.length > 0) {
4791
+ process.stdout.write(`${failedTests.map(failedTestRow).join("\n")}
4792
+ `);
4793
+ } else if (options.failed) {
4794
+ process.stdout.write("No failing tests.\n");
4795
+ }
4796
+ } catch (err) {
4797
+ handlePipelineError(err, context);
4798
+ }
4799
+ });
4800
+ return command;
4801
+ }
4802
+ function parseParameters(values) {
4803
+ const result = {};
4804
+ for (const entry of values ?? []) {
4805
+ const eq = entry.indexOf("=");
4806
+ if (eq <= 0) {
4807
+ return null;
4808
+ }
4809
+ const key = entry.slice(0, eq);
4810
+ const value = entry.slice(eq + 1);
4811
+ result[key] = value;
4812
+ }
4813
+ return result;
4814
+ }
4815
+ function collectParameter(value, previous) {
4816
+ return previous.concat([value]);
4817
+ }
4818
+ function createPipelineStartCommand() {
4819
+ const command = new Command13("start");
4820
+ command.description("Queue a new run of a pipeline definition").argument("<def_id>", "pipeline definition id").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--branch <branch>", "branch to run against (default: pipeline default branch)").option("--parameter <key=value>", "template parameter (repeatable)", collectParameter, []).option("--json", "output JSON").action(async (defIdRaw, options) => {
4821
+ validateOrgProjectPair(options);
4822
+ const defId = parsePositiveId(defIdRaw);
4823
+ if (defId === null) {
4824
+ writeError2(`Invalid definition id "${defIdRaw}"; expected a positive integer.`);
4825
+ return;
4826
+ }
4827
+ const parameters = parseParameters(options.parameter);
4828
+ if (parameters === null) {
4829
+ writeError2("Invalid --parameter; expected key=value.");
4830
+ return;
4831
+ }
4832
+ let context;
4833
+ try {
4834
+ const resolved = await resolvePipelineContext(options);
4835
+ context = resolved.context;
4836
+ const result = await runPipeline(resolved.context, resolved.cred, defId, {
4837
+ branch: options.branch,
4838
+ parameters
4839
+ });
4840
+ if (options.json) {
4841
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
4842
+ `);
4843
+ return;
4844
+ }
4845
+ process.stdout.write(`Queued run #${result.id} [${result.state}]
4846
+ ${result.webUrl ?? "\u2014"}
4847
+ `);
4848
+ } catch (err) {
4849
+ handlePipelineError(err, context);
4850
+ }
4851
+ });
4852
+ return command;
4853
+ }
4854
+ function createPipelineCommand() {
4855
+ const command = new Command13("pipeline");
4856
+ command.description("Manage Azure DevOps pipelines");
4857
+ command.addCommand(createPipelineListCommand());
4858
+ command.addCommand(createPipelineGetRunsCommand());
4859
+ command.addCommand(createPipelineWaitCommand());
4860
+ command.addCommand(createPipelineGetRunDetailCommand());
4861
+ command.addCommand(createPipelineLogsCommand());
4862
+ command.addCommand(createPipelineTestsCommand());
4863
+ command.addCommand(createPipelineStartCommand());
4864
+ return command;
4865
+ }
4866
+
4867
+ // src/commands/comments.ts
4868
+ import { Command as Command14 } from "commander";
4869
+ function writeError3(message) {
4870
+ process.stderr.write(`Error: ${message}
4871
+ `);
4872
+ process.exit(1);
4873
+ }
4874
+ function formatCommentHeader(comment) {
4875
+ const author = comment.author ?? "Unknown";
4876
+ const timestamp = comment.modifiedAt ?? comment.createdAt ?? "Unknown time";
4877
+ return `Comment #${comment.id} by ${author} at ${timestamp}`;
4878
+ }
4879
+ function formatComments(result, convertMarkdown) {
4880
+ const lines = [`Comments for work item #${result.workItemId}`];
4881
+ for (const comment of result.comments) {
4882
+ const text = convertMarkdown ? toMarkdown(comment.text) : comment.text;
4883
+ lines.push("", formatCommentHeader(comment), text);
4884
+ }
4885
+ return lines.join("\n");
4886
+ }
4887
+ function createCommentsListCommand() {
4888
+ const command = new Command14("list");
4889
+ command.description("List visible comments for a work item").argument("<id>", "work item ID").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output JSON").option("--markdown", "convert HTML comment bodies to markdown").action(async (idStr, options) => {
4890
+ validateOrgProjectPair(options);
4891
+ const id = parseWorkItemId(idStr);
4892
+ let context;
4893
+ try {
4894
+ context = resolveContext(options);
4895
+ const credential = await requireAuthCredential(context.org);
4896
+ const result = await listWorkItemComments(context, id, credential);
4897
+ if (options.json) {
4898
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
4899
+ `);
4900
+ return;
4901
+ }
4902
+ if (result.comments.length === 0) {
4903
+ process.stdout.write(`Work item #${id} has no comments.
4904
+ `);
4905
+ return;
4906
+ }
4907
+ process.stdout.write(`${formatComments(result, options.markdown === true)}
4908
+ `);
4909
+ } catch (err) {
4910
+ handleCommandError(err, id, context, "read");
4911
+ }
4912
+ });
4913
+ return command;
2937
4914
  }
2938
4915
  function createCommentsAddCommand() {
2939
- const command = new Command13("add");
4916
+ const command = new Command14("add");
2940
4917
  command.description("Add a comment to a work item").argument("<id>", "work item ID").argument("<text>", "comment text").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output JSON").option("--markdown", "post comment as markdown").action(async (idStr, text, options) => {
2941
4918
  validateOrgProjectPair(options);
2942
4919
  const id = parseWorkItemId(idStr);
2943
4920
  if (text.trim() === "") {
2944
- writeError2("Comment text must be a non-empty string.");
4921
+ writeError3("Comment text must be a non-empty string.");
2945
4922
  }
2946
4923
  let context;
2947
4924
  try {
2948
4925
  context = resolveContext(options);
2949
- const credential = await requirePat(context.org);
4926
+ const credential = await requireAuthCredential(context.org);
2950
4927
  const format = options.markdown === true ? "markdown" : "html";
2951
- const result = await addWorkItemComment(context, id, credential.pat, text, format);
4928
+ const result = await addWorkItemComment(context, id, credential, text, format);
2952
4929
  if (options.json) {
2953
4930
  process.stdout.write(`${JSON.stringify(result, null, 2)}
2954
4931
  `);
@@ -2963,7 +4940,7 @@ function createCommentsAddCommand() {
2963
4940
  return command;
2964
4941
  }
2965
4942
  function createCommentsCommand() {
2966
- const command = new Command13("comments");
4943
+ const command = new Command14("comments");
2967
4944
  command.description("Manage Azure DevOps work item comments");
2968
4945
  command.addCommand(createCommentsListCommand());
2969
4946
  command.addCommand(createCommentsAddCommand());
@@ -2971,12 +4948,12 @@ function createCommentsCommand() {
2971
4948
  }
2972
4949
 
2973
4950
  // src/commands/download-attachment.ts
2974
- import { Command as Command14 } from "commander";
2975
- import { writeFile } from "fs/promises";
2976
- import { existsSync as existsSync4 } from "fs";
2977
- import { join as join2 } from "path";
4951
+ import { Command as Command15 } from "commander";
4952
+ import { writeFile as writeFile2 } from "fs/promises";
4953
+ import { existsSync as existsSync5 } from "fs";
4954
+ import { join as join3 } from "path";
2978
4955
  function createDownloadAttachmentCommand() {
2979
- const command = new Command14("download-attachment");
4956
+ const command = new Command15("download-attachment");
2980
4957
  command.description("Download an attachment from an Azure DevOps work item").argument("<id>", "work item ID").argument("<filename>", "name of the attachment to download").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--output <dir>", "target directory for the downloaded file").action(
2981
4958
  async (idStr, filename, options) => {
2982
4959
  const id = parseWorkItemId(idStr);
@@ -2984,14 +4961,14 @@ function createDownloadAttachmentCommand() {
2984
4961
  let context;
2985
4962
  try {
2986
4963
  context = resolveContext(options);
2987
- const credential = await requirePat(context.org);
4964
+ const credential = await requireAuthCredential(context.org);
2988
4965
  const outputDir = options.output ?? ".";
2989
- if (!existsSync4(outputDir)) {
4966
+ if (!existsSync5(outputDir)) {
2990
4967
  process.stderr.write(`Error: Output directory "${outputDir}" does not exist.
2991
4968
  `);
2992
4969
  process.exit(1);
2993
4970
  }
2994
- const workItem = await getWorkItem(context, id, credential.pat);
4971
+ const workItem = await getWorkItem(context, id, credential);
2995
4972
  const attachment = workItem.attachments?.find(
2996
4973
  (a) => a.name === filename
2997
4974
  );
@@ -3002,9 +4979,9 @@ function createDownloadAttachmentCommand() {
3002
4979
  );
3003
4980
  process.exit(1);
3004
4981
  }
3005
- const data = await downloadAttachment(attachment.url, credential.pat);
3006
- const outputPath = join2(outputDir, filename);
3007
- await writeFile(outputPath, Buffer.from(data));
4982
+ const data = await downloadAttachment(attachment.url, credential);
4983
+ const outputPath = join3(outputDir, filename);
4984
+ await writeFile2(outputPath, Buffer.from(data));
3008
4985
  process.stdout.write(
3009
4986
  `Downloaded "${filename}" (${formatFileSize(attachment.size)}) to ${outputPath}
3010
4987
  `
@@ -3017,9 +4994,416 @@ function createDownloadAttachmentCommand() {
3017
4994
  return command;
3018
4995
  }
3019
4996
 
4997
+ // src/commands/relations.ts
4998
+ import { Command as Command16 } from "commander";
4999
+
5000
+ // src/services/relations-client.ts
5001
+ var API_VERSION2 = "7.1";
5002
+ function buildRelationTypesUrl(context) {
5003
+ const url = new URL(
5004
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/_apis/wit/workitemrelationtypes`
5005
+ );
5006
+ url.searchParams.set("api-version", API_VERSION2);
5007
+ return url;
5008
+ }
5009
+ function buildWorkItemUrl2(context, id, expand) {
5010
+ const url = new URL(
5011
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
5012
+ );
5013
+ url.searchParams.set("api-version", API_VERSION2);
5014
+ if (expand) url.searchParams.set("$expand", expand);
5015
+ return url;
5016
+ }
5017
+ function buildBatchWorkItemsUrl(context, ids) {
5018
+ const url = new URL(
5019
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems`
5020
+ );
5021
+ url.searchParams.set("ids", ids.join(","));
5022
+ url.searchParams.set("fields", "System.Id,System.Title");
5023
+ url.searchParams.set("api-version", API_VERSION2);
5024
+ return url;
5025
+ }
5026
+ function mapRelationType(raw) {
5027
+ return {
5028
+ referenceName: raw.referenceName,
5029
+ name: raw.name,
5030
+ usage: raw.attributes?.usage ?? "unknown",
5031
+ enabled: raw.attributes?.enabled !== false,
5032
+ directional: raw.attributes?.directional ?? null
5033
+ };
5034
+ }
5035
+ async function readJsonResponse3(response) {
5036
+ if (!response.ok) throw new Error(`HTTP_${response.status}`);
5037
+ return await response.json();
5038
+ }
5039
+ function parseTargetId(url) {
5040
+ const match = /\/workItems\/(\d+)$/i.exec(url);
5041
+ return match ? Number(match[1]) : null;
5042
+ }
5043
+ async function getWorkItemRelationTypes(context, cred) {
5044
+ const url = buildRelationTypesUrl(context);
5045
+ const response = await fetchWithErrors(url.toString(), {
5046
+ headers: authHeaders(cred)
5047
+ });
5048
+ const body = await readJsonResponse3(response);
5049
+ return (body.value ?? []).map(mapRelationType).filter((t) => t.usage === "workItemLink" && t.enabled);
5050
+ }
5051
+ async function resolveRelationType(context, cred, alias) {
5052
+ const types = await getWorkItemRelationTypes(context, cred);
5053
+ const lower = alias.toLowerCase();
5054
+ const match = types.find((t) => t.name.toLowerCase() === lower);
5055
+ if (!match) throw new Error(`UNKNOWN_RELATION_TYPE:${alias}`);
5056
+ return match;
5057
+ }
5058
+ async function getWorkItemWithRelations(context, cred, id) {
5059
+ const url = buildWorkItemUrl2(context, id, "relations");
5060
+ const response = await fetchWithErrors(url.toString(), {
5061
+ headers: authHeaders(cred)
5062
+ });
5063
+ return readJsonResponse3(response);
5064
+ }
5065
+ async function addWorkItemRelation(context, cred, type, id1, id2) {
5066
+ if (id1 === id2) throw new Error("SELF_RELATION");
5067
+ const relType = await resolveRelationType(context, cred, type);
5068
+ const workItem = await getWorkItemWithRelations(context, cred, id1);
5069
+ const targetUrl = `https://dev.azure.com/${encodeURIComponent(context.org)}/_apis/wit/workItems/${id2}`;
5070
+ const existing = (workItem.relations ?? []).find(
5071
+ (r) => r.rel === relType.referenceName && r.url.toLowerCase() === targetUrl.toLowerCase()
5072
+ );
5073
+ if (existing) {
5074
+ return { status: "already_exists", type: relType.name, referenceName: relType.referenceName, id1, id2 };
5075
+ }
5076
+ const patchUrl = buildWorkItemUrl2(context, id1);
5077
+ const response = await fetchWithErrors(patchUrl.toString(), {
5078
+ method: "PATCH",
5079
+ headers: { ...authHeaders(cred), "Content-Type": "application/json-patch+json" },
5080
+ body: JSON.stringify([
5081
+ { op: "add", path: "/relations/-", value: { rel: relType.referenceName, url: targetUrl } }
5082
+ ])
5083
+ });
5084
+ if (!response.ok) throw new Error(`HTTP_${response.status}`);
5085
+ return { status: "added", type: relType.name, referenceName: relType.referenceName, id1, id2 };
5086
+ }
5087
+ async function removeWorkItemRelation(context, cred, type, id1, id2) {
5088
+ if (id1 === id2) throw new Error("SELF_RELATION");
5089
+ const relType = await resolveRelationType(context, cred, type);
5090
+ const workItem = await getWorkItemWithRelations(context, cred, id1);
5091
+ const relations = workItem.relations ?? [];
5092
+ const index = relations.findIndex(
5093
+ (r) => r.rel === relType.referenceName && parseTargetId(r.url) === id2
5094
+ );
5095
+ if (index === -1) {
5096
+ return { status: "not_found", type: relType.name, referenceName: relType.referenceName, id1, id2 };
5097
+ }
5098
+ const patchUrl = buildWorkItemUrl2(context, id1);
5099
+ const response = await fetchWithErrors(patchUrl.toString(), {
5100
+ method: "PATCH",
5101
+ headers: { ...authHeaders(cred), "Content-Type": "application/json-patch+json" },
5102
+ body: JSON.stringify([{ op: "remove", path: `/relations/${index}` }])
5103
+ });
5104
+ if (!response.ok) throw new Error(`HTTP_${response.status}`);
5105
+ return { status: "removed", type: relType.name, referenceName: relType.referenceName, id1, id2 };
5106
+ }
5107
+ async function listWorkItemRelations(context, cred, id) {
5108
+ const workItem = await getWorkItemWithRelations(context, cred, id);
5109
+ const allRelations = workItem.relations ?? [];
5110
+ const wiRelations = allRelations.filter(
5111
+ (r) => !r.rel.startsWith("AttachedFile") && !r.rel.startsWith("Hyperlink") && !r.rel.startsWith("ArtifactLink")
5112
+ );
5113
+ if (wiRelations.length === 0) {
5114
+ return { workItemId: id, relations: [] };
5115
+ }
5116
+ const types = await getWorkItemRelationTypes(context, cred);
5117
+ const typeNameMap = new Map(types.map((t) => [t.referenceName, t.name]));
5118
+ const targetIds = wiRelations.map((r) => parseTargetId(r.url)).filter((n) => n !== null);
5119
+ const titleMap = /* @__PURE__ */ new Map();
5120
+ if (targetIds.length > 0) {
5121
+ try {
5122
+ const batchUrl = buildBatchWorkItemsUrl(context, targetIds);
5123
+ const batchResponse = await fetchWithErrors(batchUrl.toString(), {
5124
+ headers: authHeaders(cred)
5125
+ });
5126
+ const batchBody = await readJsonResponse3(batchResponse);
5127
+ for (const item of batchBody.value ?? []) {
5128
+ titleMap.set(item.id, item.fields["System.Title"] ?? "");
5129
+ }
5130
+ } catch {
5131
+ }
5132
+ }
5133
+ const relations = wiRelations.map((r) => {
5134
+ const targetId = parseTargetId(r.url);
5135
+ return {
5136
+ rel: r.rel,
5137
+ relName: typeNameMap.get(r.rel) ?? r.rel,
5138
+ targetId: targetId ?? 0,
5139
+ targetTitle: targetId !== null ? titleMap.get(targetId) ?? null : null,
5140
+ targetUrl: r.url,
5141
+ comment: r.attributes?.comment ?? null
5142
+ };
5143
+ });
5144
+ return { workItemId: id, relations };
5145
+ }
5146
+
5147
+ // src/commands/relations.ts
5148
+ function addCommonOptions(cmd) {
5149
+ return cmd.option("--json", "Output as JSON").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project");
5150
+ }
5151
+ async function resolveCredentials(opts) {
5152
+ const context = resolveContext(opts);
5153
+ const cred = await requireAuthCredential(context.org);
5154
+ return { context, cred };
5155
+ }
5156
+ function formatRelationTypes(types) {
5157
+ if (types.length === 0) return "No work item relation types found.";
5158
+ const nameWidth = Math.max(...types.map((t) => t.name.length), 4);
5159
+ const lines = ["Available work item relation types:", ""];
5160
+ for (const t of types) {
5161
+ lines.push(`${t.name.padEnd(nameWidth + 2)}${t.referenceName}`);
5162
+ }
5163
+ return lines.join("\n");
5164
+ }
5165
+ function formatRelationsList(workItemId, relations) {
5166
+ if (relations.length === 0) return `Work item #${workItemId} has no relations.`;
5167
+ const typeWidth = Math.max(...relations.map((r) => r.relName.length), 4) + 2;
5168
+ const idWidth = Math.max(...relations.map((r) => String(r.targetId).length), 4) + 1;
5169
+ const lines = [`Relations for work item #${workItemId}:`, ""];
5170
+ for (const r of relations) {
5171
+ const typeLabel = `[${r.relName}]`.padEnd(typeWidth);
5172
+ const idLabel = `#${r.targetId}`.padEnd(idWidth);
5173
+ lines.push(`${typeLabel} ${idLabel} ${r.targetTitle ?? "(unknown)"}`);
5174
+ }
5175
+ return lines.join("\n");
5176
+ }
5177
+ function handleRelationError(err, id1) {
5178
+ const msg = err instanceof Error ? err.message : String(err);
5179
+ if (msg === "SELF_RELATION") {
5180
+ process.stderr.write(`Error: cannot relate a work item to itself (#${id1}).
5181
+ `);
5182
+ } else if (msg.startsWith("UNKNOWN_RELATION_TYPE:")) {
5183
+ const name = msg.replace("UNKNOWN_RELATION_TYPE:", "");
5184
+ process.stderr.write(
5185
+ `Error: unknown relation type "${name}". Run 'azdo relations types' to see valid names.
5186
+ `
5187
+ );
5188
+ } else if (msg.startsWith("NOT_FOUND")) {
5189
+ const target = id1 !== void 0 ? id1 : "unknown";
5190
+ process.stderr.write(`Error: work item #${target} not found.
5191
+ `);
5192
+ } else if (msg === "AUTH_FAILED") {
5193
+ process.stderr.write(
5194
+ `Error: authentication failed. Check your PAT has Work Items \u2192 Read & Write scope.
5195
+ `
5196
+ );
5197
+ } else {
5198
+ process.stderr.write(`Error: ${msg}
5199
+ `);
5200
+ }
5201
+ process.exit(1);
5202
+ }
5203
+ function parsePositiveInt(value, label) {
5204
+ const n = Number(value);
5205
+ if (!Number.isInteger(n) || n <= 0) {
5206
+ process.stderr.write(`Error: ${label} must be a positive integer.
5207
+ `);
5208
+ process.exit(1);
5209
+ }
5210
+ return n;
5211
+ }
5212
+ function createRelationsCommand() {
5213
+ const relations = new Command16("relations").description("Manage work item relations");
5214
+ addCommonOptions(
5215
+ relations.command("types").description("List all available work item relation types")
5216
+ ).action(async (opts) => {
5217
+ try {
5218
+ const { context, cred } = await resolveCredentials(opts);
5219
+ const types = await getWorkItemRelationTypes(context, cred);
5220
+ if (opts.json) {
5221
+ process.stdout.write(JSON.stringify(types, null, 2) + "\n");
5222
+ } else {
5223
+ process.stdout.write(formatRelationTypes(types) + "\n");
5224
+ }
5225
+ } catch (err) {
5226
+ handleRelationError(err);
5227
+ }
5228
+ });
5229
+ addCommonOptions(
5230
+ relations.command("add <type> <id1> <id2>").description("Add a directed relation from work item <id1> to <id2>")
5231
+ ).action(async (type, id1Str, id2Str, opts) => {
5232
+ const id1 = parsePositiveInt(id1Str, "id1");
5233
+ const id2 = parsePositiveInt(id2Str, "id2");
5234
+ try {
5235
+ const { context, cred } = await resolveCredentials(opts);
5236
+ const result = await addWorkItemRelation(context, cred, type, id1, id2);
5237
+ if (opts.json) {
5238
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
5239
+ } else if (result.status === "already_exists") {
5240
+ process.stdout.write(`Relation already exists: #${id1} --[${result.type}]--> #${id2}
5241
+ `);
5242
+ } else {
5243
+ process.stdout.write(`Added relation: #${id1} --[${result.type}]--> #${id2}
5244
+ `);
5245
+ }
5246
+ } catch (err) {
5247
+ handleRelationError(err, id1);
5248
+ }
5249
+ });
5250
+ addCommonOptions(
5251
+ relations.command("remove <type> <id1> <id2>").description("Remove a directed relation of <type> from work item <id1> to <id2>")
5252
+ ).action(async (type, id1Str, id2Str, opts) => {
5253
+ const id1 = parsePositiveInt(id1Str, "id1");
5254
+ const id2 = parsePositiveInt(id2Str, "id2");
5255
+ try {
5256
+ const { context, cred } = await resolveCredentials(opts);
5257
+ const result = await removeWorkItemRelation(context, cred, type, id1, id2);
5258
+ if (opts.json) {
5259
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
5260
+ } else if (result.status === "not_found") {
5261
+ process.stdout.write(`No relation of type '${result.type}' found between #${id1} and #${id2}
5262
+ `);
5263
+ } else {
5264
+ process.stdout.write(`Removed relation: #${id1} --[${result.type}]--> #${id2}
5265
+ `);
5266
+ }
5267
+ } catch (err) {
5268
+ handleRelationError(err, id1);
5269
+ }
5270
+ });
5271
+ addCommonOptions(
5272
+ relations.command("list <id>").description("List all work item link relations on a work item")
5273
+ ).action(async (idStr, opts) => {
5274
+ const id = parsePositiveInt(idStr, "id");
5275
+ try {
5276
+ const { context, cred } = await resolveCredentials(opts);
5277
+ const result = await listWorkItemRelations(context, cred, id);
5278
+ if (opts.json) {
5279
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
5280
+ } else {
5281
+ process.stdout.write(formatRelationsList(result.workItemId, result.relations) + "\n");
5282
+ }
5283
+ } catch (err) {
5284
+ handleRelationError(err, id);
5285
+ }
5286
+ });
5287
+ return relations;
5288
+ }
5289
+
5290
+ // src/services/update-check.ts
5291
+ import fs2 from "fs";
5292
+ import path2 from "path";
5293
+ import os from "os";
5294
+ var THROTTLE_MS = 10 * 60 * 1e3;
5295
+ var FETCH_TIMEOUT_MS = 1500;
5296
+ var REGISTRY_URL = "https://registry.npmjs.org/azdo-cli/latest";
5297
+ function getCachePath() {
5298
+ return path2.join(os.homedir(), ".azdo", "update-check.json");
5299
+ }
5300
+ function defaultReadCache() {
5301
+ try {
5302
+ return fs2.readFileSync(getCachePath(), "utf-8");
5303
+ } catch {
5304
+ return null;
5305
+ }
5306
+ }
5307
+ function defaultWriteCache(data) {
5308
+ try {
5309
+ const cachePath = getCachePath();
5310
+ fs2.mkdirSync(path2.dirname(cachePath), { recursive: true });
5311
+ fs2.writeFileSync(cachePath, data);
5312
+ } catch {
5313
+ }
5314
+ }
5315
+ function parseCache(raw) {
5316
+ if (!raw) return null;
5317
+ let parsed;
5318
+ try {
5319
+ parsed = JSON.parse(raw);
5320
+ } catch {
5321
+ return null;
5322
+ }
5323
+ if (typeof parsed !== "object" || parsed === null) return null;
5324
+ const { lastCheck, latestVersion } = parsed;
5325
+ if (typeof lastCheck !== "number" || !Number.isFinite(lastCheck)) return null;
5326
+ if (typeof latestVersion !== "string" || latestVersion.length === 0) return null;
5327
+ return { lastCheck, latestVersion };
5328
+ }
5329
+ async function defaultFetchLatest() {
5330
+ const controller = new AbortController();
5331
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
5332
+ try {
5333
+ const res = await fetch(REGISTRY_URL, { signal: controller.signal });
5334
+ if (!res.ok) return null;
5335
+ const body = await res.json();
5336
+ if (typeof body !== "object" || body === null) return null;
5337
+ const v = body.version;
5338
+ return typeof v === "string" && v.length > 0 ? v : null;
5339
+ } catch {
5340
+ return null;
5341
+ } finally {
5342
+ clearTimeout(timer);
5343
+ }
5344
+ }
5345
+ function parseVersion(v) {
5346
+ if (typeof v !== "string") return null;
5347
+ const trimmed = v.trim().replace(/^v/, "");
5348
+ const match = /^(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/.exec(trimmed);
5349
+ if (!match) return null;
5350
+ const release = [Number(match[1]), Number(match[2]), Number(match[3])];
5351
+ if (!release.every(Number.isFinite)) return null;
5352
+ return { release, prerelease: match[4] !== void 0 };
5353
+ }
5354
+ function isNewer(latest, current) {
5355
+ const a = parseVersion(latest);
5356
+ const b = parseVersion(current);
5357
+ if (!a || !b) return false;
5358
+ for (let i = 0; i < 3; i++) {
5359
+ if (a.release[i] > b.release[i]) return true;
5360
+ if (a.release[i] < b.release[i]) return false;
5361
+ }
5362
+ if (a.prerelease && !b.prerelease) return false;
5363
+ if (!a.prerelease && b.prerelease) return true;
5364
+ return false;
5365
+ }
5366
+ async function getUpdateNotice(opts) {
5367
+ try {
5368
+ const {
5369
+ enabled = true,
5370
+ now = Date.now,
5371
+ readCache = defaultReadCache,
5372
+ writeCache = defaultWriteCache,
5373
+ fetchLatest = defaultFetchLatest,
5374
+ isTTY = () => Boolean(process.stderr.isTTY),
5375
+ currentVersion = version
5376
+ } = opts ?? {};
5377
+ if (enabled === false) return null;
5378
+ if (!isTTY()) return null;
5379
+ const cache = parseCache(readCache());
5380
+ const lastCheck = cache?.lastCheck ?? 0;
5381
+ if (now() - lastCheck < THROTTLE_MS) return null;
5382
+ const latest = await fetchLatest();
5383
+ if (!latest) return null;
5384
+ writeCache(JSON.stringify({ lastCheck: now(), latestVersion: latest }));
5385
+ if (isNewer(latest, currentVersion)) {
5386
+ return `A new version of azdo-cli is available: ${currentVersion} \u2192 ${latest}. Run \`npm i -g azdo-cli\` to update.`;
5387
+ }
5388
+ return null;
5389
+ } catch {
5390
+ return null;
5391
+ }
5392
+ }
5393
+
3020
5394
  // src/index.ts
3021
- var program = new Command15();
5395
+ function exitOnEpipe(err) {
5396
+ if (err.code === "EPIPE") {
5397
+ process.exit(0);
5398
+ }
5399
+ throw err;
5400
+ }
5401
+ process.stdout.on("error", exitOnEpipe);
5402
+ process.stderr.on("error", exitOnEpipe);
5403
+ var program = new Command17();
3022
5404
  program.name("azdo").description("Azure DevOps CLI tool").version(version, "-v, --version");
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)");
3023
5407
  program.addCommand(createGetItemCommand());
3024
5408
  program.addCommand(createAuthCommand());
3025
5409
  program.addCommand(createClearPatCommand());
@@ -3032,10 +5416,24 @@ program.addCommand(createSetMdFieldCommand());
3032
5416
  program.addCommand(createUpsertCommand());
3033
5417
  program.addCommand(createListFieldsCommand());
3034
5418
  program.addCommand(createPrCommand());
5419
+ program.addCommand(createPipelineCommand());
3035
5420
  program.addCommand(createCommentsCommand());
3036
5421
  program.addCommand(createDownloadAttachmentCommand());
5422
+ program.addCommand(createRelationsCommand());
3037
5423
  program.showHelpAfterError();
3038
- program.parse();
5424
+ program.hook("preAction", () => {
5425
+ const { trace } = program.opts();
5426
+ if (trace) {
5427
+ initTraceWriter(trace);
5428
+ }
5429
+ });
5430
+ program.hook("postAction", async () => {
5431
+ const notice = await getUpdateNotice({ enabled: program.opts().updateCheck });
5432
+ if (notice) {
5433
+ process.stderr.write(notice + "\n");
5434
+ }
5435
+ });
5436
+ await program.parseAsync();
3039
5437
  if (process.argv.length <= 2) {
3040
5438
  program.help();
3041
5439
  }