azdo-cli 0.5.0-next.155 → 0.5.0-next.349

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,37 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ AZDO_RESOURCE_ID,
4
+ CredentialMissingError,
5
+ CredentialStoreUnavailableError,
6
+ SETTINGS,
7
+ appendAuthAuditEvent,
8
+ buildScopeString,
9
+ defaultScopes,
10
+ deletePat,
11
+ firstPartyShippedScopes,
12
+ getConfigValue,
13
+ getPat,
14
+ getStoredCredential,
15
+ listOrgsWithStoredPat,
16
+ loadConfig,
17
+ maskedDisplay,
18
+ normalizePat,
19
+ openUrl,
20
+ probeBackend,
21
+ readAuditEvents,
22
+ readTokenResponse,
23
+ refreshIfNeeded,
24
+ resolveOAuthConfig,
25
+ runAuthCodeFlow,
26
+ setConfigValue,
27
+ storeOAuthCredential,
28
+ storePat,
29
+ tokenResponseToCredential,
30
+ unsetConfigValue
31
+ } from "./chunk-C7RAZJHV.js";
2
32
 
3
33
  // src/index.ts
4
- import { Command as Command13 } from "commander";
34
+ import { Command as Command15 } from "commander";
5
35
 
6
36
  // src/version.ts
7
37
  import { readFileSync } from "fs";
@@ -26,8 +56,15 @@ var DEFAULT_FIELDS = [
26
56
  "System.AreaPath",
27
57
  "System.IterationPath"
28
58
  ];
29
- function authHeaders(pat) {
30
- const token = Buffer.from(`:${pat}`).toString("base64");
59
+ function authHeaders(credentialOrPat) {
60
+ if (typeof credentialOrPat === "string") {
61
+ const token2 = Buffer.from(`:${credentialOrPat}`).toString("base64");
62
+ return { Authorization: `Basic ${token2}` };
63
+ }
64
+ if (credentialOrPat.kind === "oauth") {
65
+ return { Authorization: `Bearer ${credentialOrPat.pat}` };
66
+ }
67
+ const token = Buffer.from(`:${credentialOrPat.pat}`).toString("base64");
31
68
  return { Authorization: `Basic ${token}` };
32
69
  }
33
70
  async function fetchWithErrors(url, init) {
@@ -48,6 +85,10 @@ async function fetchWithErrors(url, init) {
48
85
  }
49
86
  throw new Error(`NOT_FOUND${detail}`);
50
87
  }
88
+ const contentType = response.headers?.get("content-type") ?? "";
89
+ if (contentType.toLowerCase().startsWith("text/html")) {
90
+ throw new Error("AUTH_FAILED");
91
+ }
51
92
  return response;
52
93
  }
53
94
  async function readResponseMessage(response) {
@@ -90,9 +131,9 @@ function buildExtraFields(fields, requested) {
90
131
  }
91
132
  return Object.keys(result).length > 0 ? result : null;
92
133
  }
93
- function writeHeaders(pat) {
134
+ function writeHeaders(cred) {
94
135
  return {
95
- ...authHeaders(pat),
136
+ ...authHeaders(cred),
96
137
  "Content-Type": "application/json-patch+json"
97
138
  };
98
139
  }
@@ -147,13 +188,13 @@ async function readWriteResponse(response, errorCode) {
147
188
  fields: data.fields
148
189
  };
149
190
  }
150
- async function getWorkItemFields(context, id, pat) {
191
+ async function getWorkItemFields(context, id, cred) {
151
192
  const url = new URL(
152
193
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
153
194
  );
154
195
  url.searchParams.set("api-version", "7.1");
155
196
  url.searchParams.set("$expand", "all");
156
- const response = await fetchWithErrors(url.toString(), { headers: authHeaders(pat) });
197
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
157
198
  if (response.status === 400) {
158
199
  const serverMessage = await readResponseMessage(response);
159
200
  if (serverMessage) {
@@ -166,17 +207,33 @@ async function getWorkItemFields(context, id, pat) {
166
207
  const data = await response.json();
167
208
  return data.fields;
168
209
  }
169
- async function getWorkItem(context, id, pat, extraFields) {
210
+ function extractAttachments(relations) {
211
+ if (!relations) return null;
212
+ const attachments = relations.filter((r) => r.rel === "AttachedFile").map((r) => ({
213
+ name: r.attributes.name ?? "unknown",
214
+ size: r.attributes.resourceSize ?? 0,
215
+ url: r.url
216
+ }));
217
+ return attachments.length > 0 ? attachments : null;
218
+ }
219
+ function buildWorkItemUrl(context, id, options = {}) {
170
220
  const url = new URL(
171
221
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
172
222
  );
173
223
  url.searchParams.set("api-version", "7.1");
174
- const normalizedExtraFields = extraFields ? normalizeFieldList(extraFields) : [];
175
- if (normalizedExtraFields.length > 0) {
176
- const allFields = normalizeFieldList([...DEFAULT_FIELDS, ...normalizedExtraFields]);
177
- url.searchParams.set("fields", allFields.join(","));
224
+ if (options.includeRelations) {
225
+ url.searchParams.set("$expand", "relations");
178
226
  }
179
- const response = await fetchWithErrors(url.toString(), { headers: authHeaders(pat) });
227
+ if (options.fields && options.fields.length > 0) {
228
+ url.searchParams.set("fields", options.fields.join(","));
229
+ }
230
+ return url;
231
+ }
232
+ async function fetchWorkItemResponse(context, id, cred, options = {}) {
233
+ const response = await fetchWithErrors(
234
+ buildWorkItemUrl(context, id, options).toString(),
235
+ { headers: authHeaders(cred) }
236
+ );
180
237
  if (response.status === 400) {
181
238
  const serverMessage = await readResponseMessage(response);
182
239
  if (serverMessage) {
@@ -186,7 +243,14 @@ async function getWorkItem(context, id, pat, extraFields) {
186
243
  if (!response.ok) {
187
244
  throw new Error(`HTTP_${response.status}`);
188
245
  }
189
- const data = await response.json();
246
+ return await response.json();
247
+ }
248
+ async function getWorkItem(context, id, cred, extraFields) {
249
+ const normalizedExtraFields = extraFields ? normalizeFieldList(extraFields) : [];
250
+ const data = normalizedExtraFields.length > 0 ? await fetchWorkItemResponse(context, id, cred, {
251
+ fields: normalizeFieldList([...DEFAULT_FIELDS, ...normalizedExtraFields])
252
+ }) : await fetchWorkItemResponse(context, id, cred, { includeRelations: true });
253
+ const relationsData = normalizedExtraFields.length > 0 ? await fetchWorkItemResponse(context, id, cred, { includeRelations: true }) : data;
190
254
  const descriptionParts = [];
191
255
  if (data.fields["System.Description"]) {
192
256
  descriptionParts.push({ label: "Description", value: data.fields["System.Description"] });
@@ -214,16 +278,17 @@ async function getWorkItem(context, id, pat, extraFields) {
214
278
  areaPath: data.fields["System.AreaPath"],
215
279
  iterationPath: data.fields["System.IterationPath"],
216
280
  url: data._links.html.href,
217
- extraFields: normalizedExtraFields.length > 0 ? buildExtraFields(data.fields, normalizedExtraFields) : null
281
+ extraFields: normalizedExtraFields.length > 0 ? buildExtraFields(data.fields, normalizedExtraFields) : null,
282
+ attachments: extractAttachments(relationsData.relations)
218
283
  };
219
284
  }
220
- async function getWorkItemFieldValue(context, id, pat, fieldName) {
285
+ async function getWorkItemFieldValue(context, id, cred, fieldName) {
221
286
  const url = new URL(
222
287
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
223
288
  );
224
289
  url.searchParams.set("api-version", "7.1");
225
290
  url.searchParams.set("fields", fieldName);
226
- const response = await fetchWithErrors(url.toString(), { headers: authHeaders(pat) });
291
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
227
292
  if (response.status === 400) {
228
293
  const serverMessage = await readResponseMessage(response);
229
294
  if (serverMessage) {
@@ -240,13 +305,13 @@ async function getWorkItemFieldValue(context, id, pat, fieldName) {
240
305
  }
241
306
  return stringifyFieldValue(value);
242
307
  }
243
- async function listWorkItemComments(context, id, pat) {
308
+ async function listWorkItemComments(context, id, cred) {
244
309
  const comments = [];
245
310
  let continuationToken = null;
246
311
  do {
247
312
  const response = await fetchWithErrors(
248
313
  buildWorkItemCommentsListUrl(context, id, continuationToken ?? void 0).toString(),
249
- { headers: authHeaders(pat) }
314
+ { headers: authHeaders(cred) }
250
315
  );
251
316
  if (!response.ok) {
252
317
  throw new Error(`HTTP_${response.status}`);
@@ -263,11 +328,13 @@ async function listWorkItemComments(context, id, pat) {
263
328
  comments
264
329
  };
265
330
  }
266
- async function addWorkItemComment(context, id, pat, text) {
267
- const response = await fetchWithErrors(buildWorkItemCommentsUrl(context, id).toString(), {
331
+ async function addWorkItemComment(context, id, cred, text, format = "html") {
332
+ const url = buildWorkItemCommentsUrl(context, id);
333
+ url.searchParams.set("format", format);
334
+ const response = await fetchWithErrors(url.toString(), {
268
335
  method: "POST",
269
336
  headers: {
270
- ...authHeaders(pat),
337
+ ...authHeaders(cred),
271
338
  "Content-Type": "application/json"
272
339
  },
273
340
  body: JSON.stringify({ text })
@@ -289,8 +356,8 @@ async function addWorkItemComment(context, id, pat, text) {
289
356
  url: data.url ?? null
290
357
  };
291
358
  }
292
- async function updateWorkItem(context, id, pat, fieldName, operations) {
293
- const result = await applyWorkItemPatch(context, id, pat, operations);
359
+ async function updateWorkItem(context, id, cred, fieldName, operations) {
360
+ const result = await applyWorkItemPatch(context, id, cred, operations);
294
361
  const title = result.fields["System.Title"];
295
362
  const lastOp = operations.at(-1);
296
363
  const fieldValue = lastOp?.value ?? null;
@@ -302,68 +369,165 @@ async function updateWorkItem(context, id, pat, fieldName, operations) {
302
369
  fieldValue
303
370
  };
304
371
  }
305
- async function createWorkItem(context, workItemType, pat, operations) {
372
+ async function createWorkItem(context, workItemType, cred, operations) {
306
373
  const url = new URL(
307
374
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/$${encodeURIComponent(workItemType)}`
308
375
  );
309
376
  url.searchParams.set("api-version", "7.1");
310
377
  const response = await fetchWithErrors(url.toString(), {
311
378
  method: "POST",
312
- headers: writeHeaders(pat),
379
+ headers: writeHeaders(cred),
313
380
  body: JSON.stringify(operations)
314
381
  });
315
382
  return readWriteResponse(response, "CREATE_REJECTED");
316
383
  }
317
- async function applyWorkItemPatch(context, id, pat, operations) {
384
+ async function applyWorkItemPatch(context, id, cred, operations) {
318
385
  const url = new URL(
319
386
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
320
387
  );
321
388
  url.searchParams.set("api-version", "7.1");
322
389
  const response = await fetchWithErrors(url.toString(), {
323
390
  method: "PATCH",
324
- headers: writeHeaders(pat),
391
+ headers: writeHeaders(cred),
325
392
  body: JSON.stringify(operations)
326
393
  });
327
394
  return readWriteResponse(response, "UPDATE_REJECTED");
328
395
  }
396
+ async function downloadAttachment(url, cred) {
397
+ const response = await fetchWithErrors(url, { headers: authHeaders(cred) });
398
+ if (!response.ok) {
399
+ throw new Error(`HTTP_${response.status}`);
400
+ }
401
+ return response.arrayBuffer();
402
+ }
329
403
 
330
404
  // src/services/auth.ts
331
405
  import { createInterface } from "readline";
406
+ import { existsSync, readFileSync as readFileSync2 } from "fs";
407
+ import { dirname as dirname2, join } from "path";
332
408
 
333
- // src/services/credential-store.ts
334
- import { Entry } from "@napi-rs/keyring";
335
- var SERVICE = "azdo-cli";
336
- var ACCOUNT = "pat";
337
- async function getPat() {
338
- try {
339
- const entry = new Entry(SERVICE, ACCOUNT);
340
- return entry.getPassword();
341
- } catch {
342
- return null;
409
+ // src/services/oauth-device-code.ts
410
+ var DeviceCodeFlowError = class extends Error {
411
+ reason;
412
+ constructor(reason, message, cause) {
413
+ super(message);
414
+ this.name = "DeviceCodeFlowError";
415
+ this.reason = reason;
416
+ if (cause instanceof Error) {
417
+ this.cause = cause;
418
+ }
343
419
  }
420
+ };
421
+ var MIN_INTERVAL_SEC = 5;
422
+ async function defaultSleep(ms) {
423
+ await new Promise((r) => setTimeout(r, ms));
344
424
  }
345
- async function storePat(pat) {
425
+ async function requestDeviceCode(oauthConfig, fetchFn) {
426
+ const body = new URLSearchParams({
427
+ client_id: oauthConfig.clientId,
428
+ scope: buildScopeString(oauthConfig.scopes)
429
+ });
430
+ const response = await fetchFn(oauthConfig.deviceCodeEndpoint, {
431
+ method: "POST",
432
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
433
+ body: body.toString()
434
+ });
435
+ const text = await response.text();
436
+ let parsed;
346
437
  try {
347
- const entry = new Entry(SERVICE, ACCOUNT);
348
- entry.setPassword(pat);
438
+ parsed = JSON.parse(text);
349
439
  } catch {
440
+ throw new DeviceCodeFlowError("idp-error", `device-code endpoint returned non-JSON HTTP ${response.status}: ${text.slice(0, 200)}`);
441
+ }
442
+ if (!response.ok) {
443
+ const err = parsed;
444
+ const code = err.error ?? "unknown";
445
+ const desc = err.error_description ? `: ${err.error_description}` : "";
446
+ throw new DeviceCodeFlowError(
447
+ "idp-error",
448
+ `device-code endpoint rejected request (${response.status}): ${code}${desc}`
449
+ );
350
450
  }
451
+ return parsed;
351
452
  }
352
- async function deletePat() {
453
+ async function classifyDeviceTokenResponse(response) {
454
+ if (response.ok) {
455
+ return { kind: "success", token: await readTokenResponse(response) };
456
+ }
457
+ const text = await response.text();
458
+ let parsed;
353
459
  try {
354
- const entry = new Entry(SERVICE, ACCOUNT);
355
- entry.deletePassword();
356
- return true;
460
+ parsed = JSON.parse(text);
357
461
  } catch {
358
- return false;
462
+ throw new DeviceCodeFlowError("idp-error", `non-JSON HTTP ${response.status}: ${text.slice(0, 200)}`);
463
+ }
464
+ const errCode = parsed.error ?? "";
465
+ if (errCode === "authorization_pending") return { kind: "pending" };
466
+ if (errCode === "slow_down") return { kind: "slow_down" };
467
+ if (errCode === "expired_token") {
468
+ throw new DeviceCodeFlowError("expired_token", "device code expired before authorisation completed");
469
+ }
470
+ if (errCode === "access_denied") {
471
+ throw new DeviceCodeFlowError("access_denied", "authorisation denied by user");
472
+ }
473
+ const desc = parsed.error_description ? `: ${parsed.error_description}` : "";
474
+ throw new DeviceCodeFlowError(
475
+ "idp-error",
476
+ `IdP rejected device-token poll (${response.status}): ${errCode}${desc}`
477
+ );
478
+ }
479
+ async function pollForDeviceToken(deviceCode, oauthConfig, initialIntervalSec, expiresAtMs, deps) {
480
+ const fetchFn = deps.fetch ?? fetch;
481
+ const now = deps.now ?? (() => Date.now());
482
+ const sleep = deps.sleep ?? defaultSleep;
483
+ let intervalSec = Math.max(MIN_INTERVAL_SEC, initialIntervalSec);
484
+ for (; ; ) {
485
+ if (now() >= expiresAtMs) {
486
+ throw new DeviceCodeFlowError("expired_token", "device-code flow expired before authorisation completed");
487
+ }
488
+ await sleep(intervalSec * 1e3);
489
+ const body = new URLSearchParams({
490
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
491
+ client_id: oauthConfig.clientId,
492
+ device_code: deviceCode
493
+ });
494
+ const response = await fetchFn(oauthConfig.tokenEndpoint, {
495
+ method: "POST",
496
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
497
+ body: body.toString()
498
+ });
499
+ const outcome = await classifyDeviceTokenResponse(response);
500
+ if (outcome.kind === "success") return outcome.token;
501
+ if (outcome.kind === "slow_down") {
502
+ intervalSec += 5;
503
+ }
359
504
  }
360
505
  }
506
+ async function runDeviceCodeFlow(org, oauthConfig, deps = {}) {
507
+ const fetchFn = deps.fetch ?? fetch;
508
+ const now = deps.now ?? (() => Date.now());
509
+ const writePrompt = deps.writePrompt ?? ((m) => {
510
+ process.stderr.write(m);
511
+ });
512
+ const dc = await requestDeviceCode(oauthConfig, fetchFn);
513
+ const expiryMin = Math.round(dc.expires_in / 60);
514
+ writePrompt(
515
+ `
516
+ To authenticate, open ${dc.verification_uri} in a browser and enter the code:
361
517
 
362
- // src/services/auth.ts
363
- function normalizePat(rawPat) {
364
- const trimmedPat = rawPat.trim();
365
- return trimmedPat.length > 0 ? trimmedPat : null;
518
+ ${dc.user_code}
519
+
520
+ Waiting for authorisation (expires in ${expiryMin} min)\u2026
521
+ `
522
+ );
523
+ const expiresAtMs = now() + dc.expires_in * 1e3;
524
+ const token = await pollForDeviceToken(dc.device_code, oauthConfig, dc.interval, expiresAtMs, deps);
525
+ const credential = tokenResponseToCredential(org, oauthConfig, token, now());
526
+ return { credential, flowUsed: "device-code" };
366
527
  }
528
+
529
+ // src/services/auth.ts
530
+ var PAT_PROMPT = "Enter your Azure DevOps PAT: ";
367
531
  async function promptForPat() {
368
532
  if (!process.stdin.isTTY) {
369
533
  return null;
@@ -371,12 +535,16 @@ async function promptForPat() {
371
535
  return new Promise((resolve2) => {
372
536
  const rl = createInterface({
373
537
  input: process.stdin,
374
- output: process.stderr
538
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
539
+ output: null
375
540
  });
376
- process.stderr.write("Enter your Azure DevOps PAT: ");
541
+ process.stderr.write(PAT_PROMPT);
377
542
  process.stdin.setRawMode(true);
378
543
  process.stdin.resume();
379
544
  let pat = "";
545
+ const redraw = () => {
546
+ process.stderr.write(`\r${PAT_PROMPT}${maskedDisplay(pat)}\x1B[K`);
547
+ };
380
548
  const onData = (key) => {
381
549
  const ch = key.toString("utf8");
382
550
  if (ch === "") {
@@ -394,56 +562,246 @@ async function promptForPat() {
394
562
  } else if (ch === "\x7F" || ch === "\b") {
395
563
  if (pat.length > 0) {
396
564
  pat = pat.slice(0, -1);
397
- process.stderr.write("\b \b");
565
+ redraw();
398
566
  }
399
567
  } else {
400
568
  pat += ch;
401
- process.stderr.write("*".repeat(ch.length));
569
+ redraw();
402
570
  }
403
571
  };
404
572
  process.stdin.on("data", onData);
405
573
  });
406
574
  }
407
- async function resolvePat() {
575
+ function findDotEnvPat(startDir = process.cwd()) {
576
+ let current = startDir;
577
+ while (true) {
578
+ const envFile = join(current, ".env");
579
+ if (existsSync(envFile)) {
580
+ const contents = readFileSync2(envFile, "utf8");
581
+ for (const line of contents.split("\n")) {
582
+ const match = line.match(/^AZDO_PAT\s*=\s*(.+)$/);
583
+ if (match) {
584
+ const value = match[1].trim().replace(/^["']|["']$/g, "");
585
+ if (value.length > 0) return value;
586
+ }
587
+ }
588
+ }
589
+ const parent = dirname2(current);
590
+ if (parent === current) break;
591
+ current = parent;
592
+ }
593
+ return null;
594
+ }
595
+ async function resolveAuthCredential(org) {
408
596
  const envPat = process.env.AZDO_PAT;
409
- if (envPat) {
410
- return { pat: envPat, source: "env" };
597
+ if (envPat && envPat.length > 0) {
598
+ return { pat: envPat, source: "env", kind: "pat" };
411
599
  }
412
- const storedPat = await getPat();
413
- if (storedPat !== null) {
414
- return { pat: storedPat, source: "credential-store" };
600
+ const stored = await getStoredCredential(org);
601
+ if (stored !== null) {
602
+ if (stored.kind === "pat") {
603
+ return { pat: stored.token, source: "credential-store", kind: "pat" };
604
+ }
605
+ const fresh = await refreshIfNeeded(org, stored);
606
+ return {
607
+ pat: fresh.accessToken,
608
+ source: "credential-store",
609
+ kind: "oauth",
610
+ accountId: fresh.accountId
611
+ };
612
+ }
613
+ const dotEnvPat = findDotEnvPat();
614
+ if (dotEnvPat !== null) {
615
+ return { pat: dotEnvPat, source: "env", kind: "pat" };
415
616
  }
416
- const promptedPat = await promptForPat();
417
- if (promptedPat !== null) {
418
- const normalizedPat = normalizePat(promptedPat);
419
- if (normalizedPat !== null) {
420
- await storePat(normalizedPat);
421
- return { pat: normalizedPat, source: "prompt" };
617
+ return null;
618
+ }
619
+ async function requireAuthCredential(org) {
620
+ const cred = await resolveAuthCredential(org);
621
+ if (cred !== null) {
622
+ return cred;
623
+ }
624
+ throw new CredentialMissingError(org);
625
+ }
626
+ async function validatePatAgainstAzdo(pat, org) {
627
+ const url = `https://dev.azure.com/${encodeURIComponent(org)}/_apis/projects?$top=1&api-version=7.1`;
628
+ const auth = Buffer.from(`:${pat}`).toString("base64");
629
+ const response = await fetch(url, {
630
+ headers: {
631
+ Authorization: `Basic ${auth}`,
632
+ Accept: "application/json"
633
+ }
634
+ });
635
+ if (response.status === 200) {
636
+ return { ok: true, status: 200 };
637
+ }
638
+ if (response.status === 401 || response.status === 403) {
639
+ return { ok: false, status: response.status };
640
+ }
641
+ throw new Error(`Azure DevOps returned HTTP ${response.status} while validating PAT for org "${org}".`);
642
+ }
643
+ var LOGIN_FAILURE_REASONS = /* @__PURE__ */ new Set([
644
+ "user-cancelled",
645
+ "port-conflict",
646
+ "state-mismatch",
647
+ "redirect-mismatch",
648
+ "idp-error",
649
+ "timeout",
650
+ "expired_token",
651
+ "access_denied"
652
+ ]);
653
+ function extractLoginFailureReason(err) {
654
+ if (typeof err === "object" && err !== null && "reason" in err) {
655
+ const r = err.reason;
656
+ if (typeof r === "string" && LOGIN_FAILURE_REASONS.has(r)) {
657
+ return r;
422
658
  }
423
659
  }
424
- throw new Error(
425
- "Authentication cancelled. Set AZDO_PAT environment variable or run again to enter a PAT."
426
- );
660
+ return "unknown";
661
+ }
662
+ async function loginWithOAuth(org, opts = {}) {
663
+ const oauthConfig = resolveOAuthConfig({
664
+ clientIdOverride: opts.clientIdOverride,
665
+ tenantIdOverride: opts.tenantIdOverride,
666
+ scopesOverride: opts.scopesOverride
667
+ });
668
+ const isHeadlessRuntime = () => {
669
+ if (opts.forceHeadless) return true;
670
+ if (process.platform === "linux") {
671
+ return !process.env.DISPLAY || process.env.DISPLAY.length === 0;
672
+ }
673
+ return false;
674
+ };
675
+ const useDeviceCode = opts.flow === "device-code" || opts.flow !== "auth-code" && isHeadlessRuntime();
676
+ appendAuthAuditEvent({
677
+ event: "oauth-login-started",
678
+ org,
679
+ backend: probeBackend(),
680
+ flow: useDeviceCode ? "device-code" : "auth-code",
681
+ clientIdSource: oauthConfig.clientIdSource
682
+ });
683
+ let credential;
684
+ let flowUsed;
685
+ try {
686
+ if (useDeviceCode) {
687
+ const r = await runDeviceCodeFlow(org, oauthConfig);
688
+ credential = r.credential;
689
+ flowUsed = "device-code";
690
+ } else {
691
+ const r = await runAuthCodeFlow(org, oauthConfig);
692
+ credential = r.credential;
693
+ flowUsed = "auth-code";
694
+ }
695
+ } catch (err) {
696
+ appendAuthAuditEvent({
697
+ event: "oauth-login-failed",
698
+ org,
699
+ backend: probeBackend(),
700
+ flow: useDeviceCode ? "device-code" : "auth-code",
701
+ reason: extractLoginFailureReason(err)
702
+ });
703
+ throw err;
704
+ }
705
+ await storeOAuthCredential(org, credential);
706
+ appendAuthAuditEvent({
707
+ event: "oauth-login-success",
708
+ org,
709
+ backend: probeBackend(),
710
+ flow: flowUsed,
711
+ clientIdSource: oauthConfig.clientIdSource,
712
+ accountId: credential.accountId,
713
+ scope: credential.scope,
714
+ tokenLifetimeSec: credential.expiresAt - credential.issuedAt
715
+ });
716
+ return {
717
+ org,
718
+ kind: "oauth",
719
+ accountId: credential.accountId,
720
+ expiresAt: credential.expiresAt,
721
+ scope: credential.scope,
722
+ flowUsed
723
+ };
724
+ }
725
+ async function logout(opts = {}) {
726
+ if (opts.all) {
727
+ const orgs = await listOrgsWithStoredPat();
728
+ const removed = [];
729
+ for (const o of orgs) {
730
+ const cred2 = await getStoredCredential(o);
731
+ const ok2 = await deletePat(o);
732
+ if (ok2 && cred2 !== null) {
733
+ removed.push({ org: o, kind: cred2.kind });
734
+ }
735
+ }
736
+ return { removed };
737
+ }
738
+ if (!opts.org) {
739
+ throw new Error("logout requires an org or --all");
740
+ }
741
+ const cred = await getStoredCredential(opts.org);
742
+ const ok = await deletePat(opts.org);
743
+ return { removed: ok && cred !== null ? [{ org: opts.org, kind: cred.kind }] : [] };
744
+ }
745
+ async function status() {
746
+ const orgs = await listOrgsWithStoredPat();
747
+ const out = [];
748
+ for (const org of orgs) {
749
+ const cred = await getStoredCredential(org);
750
+ if (cred === null) continue;
751
+ if (cred.kind === "pat") {
752
+ out.push({ org, kind: "pat", backend: probeBackend() });
753
+ } else {
754
+ out.push({
755
+ org,
756
+ kind: "oauth",
757
+ accountId: cred.accountId,
758
+ expiresAt: cred.expiresAt,
759
+ scope: cred.scope,
760
+ backend: probeBackend()
761
+ });
762
+ }
763
+ }
764
+ return { orgs: out };
427
765
  }
428
766
 
429
767
  // src/services/git-remote.ts
430
768
  import { execSync } from "child_process";
769
+
770
+ // src/services/remote-warning.ts
771
+ var WARNING = "azdo: warning: origin includes embedded credentials; consider removing them with 'git remote set-url origin <clean-url>'\n";
772
+ var warned = false;
773
+ function noticeCredentialBearingRemote() {
774
+ if (warned) {
775
+ return;
776
+ }
777
+ warned = true;
778
+ try {
779
+ process.stderr.write(WARNING);
780
+ } catch {
781
+ }
782
+ }
783
+
784
+ // src/services/git-remote.ts
431
785
  var patterns = [
432
- // HTTPS (current): https://dev.azure.com/{org}/{project}/_git/{repo}
433
- /^https?:\/\/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+)$/,
434
- // HTTPS (legacy + DefaultCollection): https://{org}.visualstudio.com/DefaultCollection/{project}/_git/{repo}
435
- /^https?:\/\/([^.]+)\.visualstudio\.com\/DefaultCollection\/([^/]+)\/_git\/([^/]+)$/,
436
- // HTTPS (legacy): https://{org}.visualstudio.com/{project}/_git/{repo}
437
- /^https?:\/\/([^.]+)\.visualstudio\.com\/([^/]+)\/_git\/([^/]+)$/,
438
- // SSH (current): git@ssh.dev.azure.com:v3/{org}/{project}/{repo}
439
- /^git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/([^/]+)$/,
440
- // SSH (legacy): {org}@vs-ssh.visualstudio.com:v3/{org}/{project}/{repo}
441
- /^[^@]+@vs-ssh\.visualstudio\.com:v3\/([^/]+)\/([^/]+)\/([^/]+)$/
786
+ // HTTPS (current): https://[user[:token]@]dev.azure.com/{org}/{project}/_git/{repo}[.git]
787
+ /^https?:\/\/(?:[^@/]+@)?dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+?)(?:\.git)?$/,
788
+ // HTTPS (legacy + DefaultCollection): https://[user[:token]@]{org}.visualstudio.com/DefaultCollection/{project}/_git/{repo}[.git]
789
+ /^https?:\/\/(?:[^@/]+@)?([^.]+)\.visualstudio\.com\/DefaultCollection\/([^/]+)\/_git\/([^/]+?)(?:\.git)?$/,
790
+ // HTTPS (legacy): https://[user[:token]@]{org}.visualstudio.com/{project}/_git/{repo}[.git]
791
+ /^https?:\/\/(?:[^@/]+@)?([^.]+)\.visualstudio\.com\/([^/]+)\/_git\/([^/]+?)(?:\.git)?$/,
792
+ // SSH (current): git@ssh.dev.azure.com:v3/{org}/{project}/{repo}[.git]
793
+ /^git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?$/,
794
+ // SSH (legacy): {org}@vs-ssh.visualstudio.com:v3/{org}/{project}/{repo}[.git]
795
+ /^[^@]+@vs-ssh\.visualstudio\.com:v3\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?$/
442
796
  ];
797
+ var httpsUserinfo = /^https?:\/\/[^@/]+@/;
443
798
  function parseAzdoRemote(url) {
444
799
  for (const pattern of patterns) {
445
800
  const match = pattern.exec(url);
446
801
  if (match) {
802
+ if (httpsUserinfo.test(url)) {
803
+ noticeCredentialBearingRemote();
804
+ }
447
805
  const project = match[2];
448
806
  if (/^DefaultCollection$/i.test(project)) {
449
807
  return { org: match[1], project: "" };
@@ -470,6 +828,9 @@ function parseRepoName(url) {
470
828
  for (const pattern of patterns) {
471
829
  const match = pattern.exec(url);
472
830
  if (match) {
831
+ if (httpsUserinfo.test(url)) {
832
+ noticeCredentialBearingRemote();
833
+ }
473
834
  return match[3];
474
835
  }
475
836
  }
@@ -496,122 +857,57 @@ function getCurrentBranch() {
496
857
  return branch;
497
858
  }
498
859
 
499
- // src/services/config-store.ts
500
- import fs from "fs";
501
- import path from "path";
502
- import os from "os";
503
- var SETTINGS = [
504
- {
505
- key: "org",
506
- description: "Azure DevOps organization name",
507
- type: "string",
508
- example: "mycompany",
509
- required: true
510
- },
511
- {
512
- key: "project",
513
- description: "Azure DevOps project name",
514
- type: "string",
515
- example: "MyProject",
516
- required: true
517
- },
518
- {
519
- key: "fields",
520
- description: "Extra work item fields to include (comma-separated reference names)",
521
- type: "string[]",
522
- example: "System.Tags,Custom.Priority",
523
- required: false
524
- },
525
- {
526
- key: "markdown",
527
- description: "Convert rich text fields to markdown on display",
528
- type: "boolean",
529
- example: "true",
530
- required: false
531
- }
532
- ];
533
- var VALID_KEYS = SETTINGS.map((s) => s.key);
534
- function getConfigPath() {
535
- return path.join(os.homedir(), ".azdo", "config.json");
536
- }
537
- function loadConfig() {
538
- const configPath = getConfigPath();
539
- let raw;
540
- try {
541
- raw = fs.readFileSync(configPath, "utf-8");
542
- } catch (err) {
543
- if (err.code === "ENOENT") {
544
- return {};
545
- }
546
- throw err;
547
- }
860
+ // src/services/org-resolver.ts
861
+ function defaultDetectFromGit() {
548
862
  try {
549
- return JSON.parse(raw);
863
+ return detectAzdoContext().org ?? null;
550
864
  } catch {
551
- process.stderr.write(`Warning: Config file ${configPath} contains invalid JSON. Using defaults.
552
- `);
553
- return {};
865
+ return null;
554
866
  }
555
867
  }
556
- function saveConfig(config) {
557
- const configPath = getConfigPath();
558
- const dir = path.dirname(configPath);
559
- fs.mkdirSync(dir, { recursive: true });
560
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
868
+ function defaultReadConfig() {
869
+ return loadConfig();
561
870
  }
562
- function validateKey(key) {
563
- if (!VALID_KEYS.includes(key)) {
564
- throw new Error(`Unknown setting key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
871
+ function resolveOrg(options) {
872
+ if (options.org && options.org.length > 0) {
873
+ return { org: options.org, source: "flag" };
565
874
  }
566
- }
567
- function getConfigValue(key) {
568
- validateKey(key);
569
- const config = loadConfig();
570
- return config[key];
571
- }
572
- function setConfigValue(key, value) {
573
- validateKey(key);
574
- const config = loadConfig();
575
- if (value === "") {
576
- delete config[key];
577
- } else if (key === "markdown") {
578
- if (value !== "true" && value !== "false") {
579
- throw new Error(`Invalid value "${value}" for markdown. Must be "true" or "false".`);
580
- }
581
- config.markdown = value === "true";
582
- } else if (key === "fields") {
583
- config.fields = value.split(",").map((s) => s.trim());
584
- } else {
585
- config[key] = value;
875
+ const gitOrg = (options.detectFromGit ?? defaultDetectFromGit)();
876
+ if (gitOrg && gitOrg.length > 0) {
877
+ return { org: gitOrg, source: "git" };
878
+ }
879
+ const configOrg = (options.readConfig ?? defaultReadConfig)().org;
880
+ if (configOrg && configOrg.length > 0) {
881
+ return { org: configOrg, source: "config" };
586
882
  }
587
- saveConfig(config);
883
+ return null;
588
884
  }
589
- function unsetConfigValue(key) {
590
- validateKey(key);
591
- const config = loadConfig();
592
- delete config[key];
593
- saveConfig(config);
885
+ function formatResolutionError() {
886
+ return [
887
+ "Could not resolve an Azure DevOps organization. Options (in priority order):",
888
+ " 1. Pass --org <name> on the command line.",
889
+ " 2. Run this command from a git repo whose origin remote is an Azure DevOps URL.",
890
+ " 3. Run `azdo config set org <name>` once to set a persistent default."
891
+ ].join("\n");
594
892
  }
595
893
 
596
894
  // src/services/context.ts
597
895
  function resolveContext(options) {
598
- if (options.org && options.project) {
599
- return { org: options.org, project: options.project };
600
- }
601
- const config = loadConfig();
602
- if (config.org && config.project) {
603
- return { org: config.org, project: config.project };
604
- }
896
+ const resolvedOrg = resolveOrg({ org: options.org });
605
897
  let gitContext = null;
606
898
  try {
607
899
  gitContext = detectAzdoContext();
608
900
  } catch {
609
901
  }
610
- const org = config.org || gitContext?.org;
611
- const project = config.project || gitContext?.project;
902
+ const config = loadConfig();
903
+ const org = resolvedOrg?.org;
904
+ const project = options.project || (gitContext?.project && gitContext.project.length > 0 ? gitContext.project : void 0) || config.project;
612
905
  if (org && project) {
613
906
  return { org, project };
614
907
  }
908
+ if (!org) {
909
+ throw new Error(formatResolutionError());
910
+ }
615
911
  throw new Error(
616
912
  'Could not determine org/project. Use --org and --project flags, work from an Azure DevOps git repo, or run "azdo config set org/project".'
617
913
  );
@@ -771,32 +1067,81 @@ function stripHtml(html) {
771
1067
  text = text.replaceAll(/<br\s*\/?>/gi, "\n");
772
1068
  text = text.replaceAll(/<\/?(p|div)>/gi, "\n");
773
1069
  text = text.replaceAll(/<li>/gi, "\n");
774
- text = text.replaceAll(/<[^>]*>/g, "");
775
- text = text.replaceAll("&amp;", "&");
1070
+ text = removeHtmlTags(text);
776
1071
  text = text.replaceAll("&lt;", "<");
777
1072
  text = text.replaceAll("&gt;", ">");
778
1073
  text = text.replaceAll("&quot;", '"');
779
1074
  text = text.replaceAll("&#39;", "'");
780
1075
  text = text.replaceAll("&nbsp;", " ");
1076
+ text = text.replaceAll("&amp;", "&");
781
1077
  text = text.replaceAll(/\n{3,}/g, "\n\n");
782
1078
  return text.trim();
783
1079
  }
1080
+ function removeHtmlTags(value) {
1081
+ let result = "";
1082
+ let insideTag = false;
1083
+ for (const char of value) {
1084
+ if (char === "<") {
1085
+ insideTag = true;
1086
+ continue;
1087
+ }
1088
+ if (char === ">" && insideTag) {
1089
+ insideTag = false;
1090
+ continue;
1091
+ }
1092
+ if (!insideTag) {
1093
+ result += char;
1094
+ }
1095
+ }
1096
+ return result;
1097
+ }
784
1098
  function convertRichText(html, markdown) {
785
1099
  if (!html) return "";
786
1100
  return markdown ? toMarkdown(html) : stripHtml(html);
787
1101
  }
1102
+ function formatMarkdownField(fieldLabel, value) {
1103
+ if (value.includes("\n")) {
1104
+ return `${fieldLabel}:
1105
+ ${value}`;
1106
+ }
1107
+ return `${fieldLabel}: ${value}`;
1108
+ }
788
1109
  function formatExtraFields(extraFields, markdown) {
789
1110
  return Object.entries(extraFields).map(([refName, value]) => {
790
1111
  const fieldLabel = refName.includes(".") ? refName.split(".").pop() : refName;
791
- const displayValue = markdown ? toMarkdown(value) : value;
792
- return `${fieldLabel.padEnd(13)}${displayValue}`;
1112
+ if (markdown) {
1113
+ const displayValue = toMarkdown(value);
1114
+ return formatMarkdownField(fieldLabel, displayValue);
1115
+ }
1116
+ return formatMarkdownField(fieldLabel, value);
793
1117
  });
794
1118
  }
795
- function summarizeDescription(text, label) {
1119
+ function summarizeDescription(text, label, markdown) {
796
1120
  const descLines = text.split("\n").filter((l) => l.trim() !== "");
797
1121
  const firstThree = descLines.slice(0, 3);
798
1122
  const suffix = descLines.length > 3 ? "\n..." : "";
799
- return [`${label("Description:")}${firstThree.join("\n")}${suffix}`];
1123
+ const content = `${firstThree.join("\n")}${suffix}`;
1124
+ if (markdown) {
1125
+ return [formatMarkdownField("Description", content)];
1126
+ }
1127
+ return [`${label("Description:")}${content}`];
1128
+ }
1129
+ function formatFileSize(bytes) {
1130
+ if (bytes < 1024) return `${bytes} B`;
1131
+ const kb = bytes / 1024;
1132
+ if (kb < 1024) return `${kb.toFixed(1)} KB`;
1133
+ const mb = kb / 1024;
1134
+ return `${mb.toFixed(1)} MB`;
1135
+ }
1136
+ function formatAttachments(attachments, short) {
1137
+ if (short) {
1138
+ return [`Attachments: ${attachments.length}`];
1139
+ }
1140
+ const lines = ["", "Attachments:"];
1141
+ for (const att of attachments) {
1142
+ lines.push(` ${att.name} (${formatFileSize(att.size)})`);
1143
+ }
1144
+ return lines;
800
1145
  }
801
1146
  function formatWorkItem(workItem, short, markdown = false) {
802
1147
  const label = (name) => name.padEnd(13);
@@ -820,10 +1165,13 @@ function formatWorkItem(workItem, short, markdown = false) {
820
1165
  lines.push("");
821
1166
  const descriptionText = convertRichText(workItem.description, markdown);
822
1167
  if (short) {
823
- lines.push(...summarizeDescription(descriptionText, label));
1168
+ lines.push(...summarizeDescription(descriptionText, label, markdown));
824
1169
  } else {
825
1170
  lines.push("Description:", descriptionText);
826
1171
  }
1172
+ if (workItem.attachments) {
1173
+ lines.push(...formatAttachments(workItem.attachments, short));
1174
+ }
827
1175
  return lines.join("\n");
828
1176
  }
829
1177
  function createGetItemCommand() {
@@ -835,9 +1183,9 @@ function createGetItemCommand() {
835
1183
  let context;
836
1184
  try {
837
1185
  context = resolveContext(options);
838
- const credential = await resolvePat();
1186
+ const credential = await requireAuthCredential(context.org);
839
1187
  const fieldsList = options.fields === void 0 ? parseRequestedFields(loadConfig().fields) : parseRequestedFields(options.fields);
840
- const workItem = await getWorkItem(context, id, credential.pat, fieldsList);
1188
+ const workItem = await getWorkItem(context, id, credential, fieldsList);
841
1189
  const markdownEnabled = options.markdown ?? loadConfig().markdown ?? false;
842
1190
  const output = formatWorkItem(workItem, options.short ?? false, markdownEnabled);
843
1191
  process.stdout.write(output + "\n");
@@ -853,19 +1201,455 @@ function createGetItemCommand() {
853
1201
  import { Command as Command2 } from "commander";
854
1202
  function createClearPatCommand() {
855
1203
  const command = new Command2("clear-pat");
856
- command.description("Remove the stored Azure DevOps PAT from the credential store").action(async () => {
857
- const deleted = await deletePat();
1204
+ command.description("Remove a stored Azure DevOps PAT (deprecated: use `azdo auth logout`)").option("--org <name>", "Azure DevOps organization (overrides auto-detect / config)").action(async (options) => {
1205
+ process.stderr.write("`azdo clear-pat` is deprecated; use `azdo auth logout [--org <name>]` instead.\n");
1206
+ const resolved = resolveOrg({ org: options.org });
1207
+ if (!resolved) {
1208
+ process.stderr.write(`${formatResolutionError()}
1209
+ `);
1210
+ process.exitCode = 3;
1211
+ return;
1212
+ }
1213
+ const deleted = await deletePat(resolved.org);
858
1214
  if (deleted) {
859
- process.stdout.write("PAT removed from credential store.\n");
1215
+ process.stdout.write(`PAT removed for org ${resolved.org}.
1216
+ `);
860
1217
  } else {
861
- process.stdout.write("No stored PAT found.\n");
1218
+ process.stdout.write(`No stored PAT found for org ${resolved.org}.
1219
+ `);
862
1220
  }
863
1221
  });
864
1222
  return command;
865
1223
  }
866
1224
 
867
- // src/commands/config.ts
1225
+ // src/commands/auth.ts
868
1226
  import { Command as Command3 } from "commander";
1227
+ async function readStdinToString() {
1228
+ const chunks = [];
1229
+ for await (const chunk of process.stdin) {
1230
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
1231
+ }
1232
+ return Buffer.concat(chunks).toString("utf8");
1233
+ }
1234
+ async function promptYesNo(prompt) {
1235
+ if (!process.stdin.isTTY) return true;
1236
+ process.stderr.write(prompt);
1237
+ return await new Promise((resolve2) => {
1238
+ process.stdin.setEncoding("utf8");
1239
+ let answered = false;
1240
+ const handler = (data) => {
1241
+ if (answered) return;
1242
+ answered = true;
1243
+ process.stdin.removeListener("data", handler);
1244
+ process.stdin.pause();
1245
+ const trimmed = data.trim().toLowerCase();
1246
+ resolve2(trimmed === "y" || trimmed === "yes");
1247
+ };
1248
+ process.stdin.resume();
1249
+ process.stdin.on("data", handler);
1250
+ });
1251
+ }
1252
+ async function confirmOverwrite(org) {
1253
+ return promptYesNo(`A PAT is already stored for org ${org}. Overwrite? [y/N] `);
1254
+ }
1255
+ async function confirmOverwriteCredential(org, existingKind) {
1256
+ const label = existingKind === "oauth" ? "OAuth credential" : "PAT";
1257
+ return promptYesNo(`A ${label} is already stored for org ${org}. The new login will replace it. Continue? [y/N] `);
1258
+ }
1259
+ function rejectMutuallyExclusive(opts) {
1260
+ if (opts.usePat && opts.deviceCode) {
1261
+ return "--use-pat and --device-code are mutually exclusive (PAT has no device-code flow).";
1262
+ }
1263
+ if (opts.usePat && (opts.clientId || opts.tenantId || opts.scopes)) {
1264
+ return "--use-pat cannot be combined with OAuth-only flags (--client-id / --tenant-id / --scopes).";
1265
+ }
1266
+ return null;
1267
+ }
1268
+ async function handlePatLogin(options) {
1269
+ const resolved = resolveOrg({ org: options.org });
1270
+ if (!resolved) {
1271
+ process.stderr.write(`${formatResolutionError()}
1272
+ `);
1273
+ process.exitCode = 3;
1274
+ return;
1275
+ }
1276
+ const org = resolved.org;
1277
+ const wantBrowser = options.browser !== false && !options.fromStdin;
1278
+ if (wantBrowser) {
1279
+ const url = `https://dev.azure.com/${encodeURIComponent(org)}/_usersSettings/tokens`;
1280
+ await openUrl(url);
1281
+ }
1282
+ const raw = options.fromStdin ? await readStdinToString() : await promptForPat();
1283
+ const pat = raw ? normalizePat(raw) : null;
1284
+ if (!pat) {
1285
+ process.stderr.write("No PAT provided. Aborting.\n");
1286
+ process.exitCode = 1;
1287
+ return;
1288
+ }
1289
+ let validation;
1290
+ try {
1291
+ validation = await validatePatAgainstAzdo(pat, org);
1292
+ } catch (err) {
1293
+ process.stderr.write(`Could not reach Azure DevOps to validate PAT: ${err.message}
1294
+ `);
1295
+ process.exitCode = 1;
1296
+ return;
1297
+ }
1298
+ if (!validation.ok) {
1299
+ appendAuthAuditEvent({ event: "auth.validate.fail", org, backend: probeBackend() });
1300
+ process.stderr.write(`PAT validation failed (HTTP ${validation.status}). Token NOT stored.
1301
+ `);
1302
+ process.exitCode = 2;
1303
+ return;
1304
+ }
1305
+ appendAuthAuditEvent({
1306
+ event: "auth.validate.ok",
1307
+ org,
1308
+ backend: probeBackend(),
1309
+ masked_pat: maskedDisplay(pat)
1310
+ });
1311
+ try {
1312
+ const existing = await getPat(org);
1313
+ if (existing !== null) {
1314
+ const overwrite = await confirmOverwrite(org);
1315
+ if (!overwrite) {
1316
+ process.stderr.write("Aborted. Existing PAT preserved.\n");
1317
+ process.exitCode = 1;
1318
+ return;
1319
+ }
1320
+ }
1321
+ await storePat(org, pat);
1322
+ } catch (err) {
1323
+ if (err instanceof CredentialStoreUnavailableError) {
1324
+ process.stderr.write(`${err.message}
1325
+ `);
1326
+ process.exitCode = 4;
1327
+ return;
1328
+ }
1329
+ throw err;
1330
+ }
1331
+ process.stdout.write(`PAT stored for org ${org} in ${probeBackend()}.
1332
+ `);
1333
+ }
1334
+ async function ensureOverwriteConfirmed(org) {
1335
+ try {
1336
+ const existing = await getStoredCredential(org);
1337
+ if (existing === null || !process.stdin.isTTY) return "ok";
1338
+ const ok = await confirmOverwriteCredential(org, existing.kind);
1339
+ return ok ? "ok" : "aborted";
1340
+ } catch (err) {
1341
+ if (err instanceof CredentialStoreUnavailableError) {
1342
+ process.stderr.write(`${err.message}
1343
+ `);
1344
+ return "unavailable";
1345
+ }
1346
+ throw err;
1347
+ }
1348
+ }
1349
+ function buildOAuthLoginOptions(options) {
1350
+ return {
1351
+ flow: options.deviceCode ? "device-code" : "auto",
1352
+ clientIdOverride: options.clientId,
1353
+ tenantIdOverride: options.tenantId,
1354
+ scopesOverride: options.scopes ? options.scopes.split(/\s+/).filter(Boolean) : void 0
1355
+ };
1356
+ }
1357
+ function reportOAuthFailure(err) {
1358
+ const reason = typeof err === "object" && err !== null && "reason" in err ? err.reason : null;
1359
+ const msg = err.message;
1360
+ process.stderr.write(reason ? `OAuth login failed (${reason}): ${msg}
1361
+ ` : `OAuth login failed: ${msg}
1362
+ `);
1363
+ const noDisplay = process.platform === "linux" && (!process.env.DISPLAY || process.env.DISPLAY.length === 0);
1364
+ if (noDisplay) {
1365
+ process.stderr.write("Tip: this host has no DISPLAY; pass --device-code to use the headless flow.\n");
1366
+ } else if (reason === "port-conflict") {
1367
+ process.stderr.write("Tip: another process is using the loopback callback port. Try again or pass --device-code.\n");
1368
+ }
1369
+ }
1370
+ async function handleOAuthLogin(options) {
1371
+ const resolved = resolveOrg({ org: options.org });
1372
+ if (!resolved) {
1373
+ process.stderr.write(`${formatResolutionError()}
1374
+ `);
1375
+ process.exitCode = 3;
1376
+ return;
1377
+ }
1378
+ const org = resolved.org;
1379
+ const confirm = await ensureOverwriteConfirmed(org);
1380
+ if (confirm === "aborted") {
1381
+ process.stderr.write("Aborted. Existing credential preserved.\n");
1382
+ process.exitCode = 1;
1383
+ return;
1384
+ }
1385
+ if (confirm === "unavailable") {
1386
+ process.exitCode = 4;
1387
+ return;
1388
+ }
1389
+ try {
1390
+ const result = await loginWithOAuth(org, buildOAuthLoginOptions(options));
1391
+ process.stdout.write(
1392
+ `Logged in to ${org} via OAuth (${result.flowUsed}). Account: ${result.accountId}; expires ${new Date(result.expiresAt * 1e3).toISOString()}.
1393
+ `
1394
+ );
1395
+ } catch (err) {
1396
+ reportOAuthFailure(err);
1397
+ process.exitCode = 1;
1398
+ }
1399
+ }
1400
+ async function handleAuthRoot(options) {
1401
+ const conflict = rejectMutuallyExclusive(options);
1402
+ if (conflict) {
1403
+ process.stderr.write(`${conflict}
1404
+ `);
1405
+ process.exitCode = 2;
1406
+ return;
1407
+ }
1408
+ await handlePatLogin(options);
1409
+ }
1410
+ async function handleLoginSubcommand(options) {
1411
+ const conflict = rejectMutuallyExclusive(options);
1412
+ if (conflict) {
1413
+ process.stderr.write(`${conflict}
1414
+ `);
1415
+ process.exitCode = 2;
1416
+ return;
1417
+ }
1418
+ if (options.fromStdin || options.usePat) {
1419
+ await handlePatLogin(options);
1420
+ return;
1421
+ }
1422
+ await handleOAuthLogin(options);
1423
+ }
1424
+ async function handleStatusJson() {
1425
+ try {
1426
+ const report = await status();
1427
+ process.stdout.write(`${JSON.stringify(report)}
1428
+ `);
1429
+ } catch (err) {
1430
+ if (err instanceof CredentialStoreUnavailableError) {
1431
+ process.stderr.write(`${err.message}
1432
+ `);
1433
+ process.exitCode = 4;
1434
+ return;
1435
+ }
1436
+ throw err;
1437
+ }
1438
+ }
1439
+ async function handleStatus(options, org) {
1440
+ if (options.json) {
1441
+ let backend2;
1442
+ let value2;
1443
+ try {
1444
+ backend2 = probeBackend();
1445
+ value2 = await getPat(org);
1446
+ } catch (err) {
1447
+ if (err instanceof CredentialStoreUnavailableError) {
1448
+ process.stderr.write(`${err.message}
1449
+ `);
1450
+ process.exitCode = 4;
1451
+ return;
1452
+ }
1453
+ throw err;
1454
+ }
1455
+ const storedEvents2 = readAuditEvents().filter((ev) => ev.org === org && ev.event === "auth.store");
1456
+ const last2 = storedEvents2.at(-1);
1457
+ const updatedAt2 = last2?.ts ?? null;
1458
+ if (!value2) {
1459
+ process.stdout.write(
1460
+ `${JSON.stringify({ org, backend: backend2, stored: false, masked: null, updated_at: updatedAt2 })}
1461
+ `
1462
+ );
1463
+ process.exitCode = 1;
1464
+ return;
1465
+ }
1466
+ const masked2 = maskedDisplay(value2);
1467
+ process.stdout.write(
1468
+ `${JSON.stringify({ org, backend: backend2, stored: true, masked: masked2, updated_at: updatedAt2 })}
1469
+ `
1470
+ );
1471
+ return;
1472
+ }
1473
+ let backend;
1474
+ let value;
1475
+ try {
1476
+ backend = probeBackend();
1477
+ value = await getPat(org);
1478
+ } catch (err) {
1479
+ if (err instanceof CredentialStoreUnavailableError) {
1480
+ process.stderr.write(`${err.message}
1481
+ `);
1482
+ process.exitCode = 4;
1483
+ return;
1484
+ }
1485
+ throw err;
1486
+ }
1487
+ const storedEvents = readAuditEvents().filter((ev) => ev.org === org && ev.event === "auth.store");
1488
+ const last = storedEvents.at(-1);
1489
+ const updatedAt = last?.ts ?? null;
1490
+ if (!value) {
1491
+ process.stdout.write(`Organization: ${org}
1492
+ Backend: ${backend}
1493
+ Stored: no
1494
+ `);
1495
+ process.exitCode = 1;
1496
+ return;
1497
+ }
1498
+ const masked = maskedDisplay(value);
1499
+ process.stdout.write(
1500
+ `Organization: ${org}
1501
+ Backend: ${backend}
1502
+ Stored: yes
1503
+ Identifier: ${masked}
1504
+ ` + (updatedAt ? `Last updated: ${updatedAt}
1505
+ ` : "")
1506
+ );
1507
+ }
1508
+ async function handleLogout(options, orgFromGlobal) {
1509
+ if (options.all && orgFromGlobal) {
1510
+ process.stderr.write("--org and --all are mutually exclusive.\n");
1511
+ process.exitCode = 1;
1512
+ return;
1513
+ }
1514
+ if (options.all) {
1515
+ try {
1516
+ const result = await logout({ all: true });
1517
+ if (result.removed.length === 0) {
1518
+ process.stdout.write("No stored credentials to remove.\n");
1519
+ return;
1520
+ }
1521
+ for (const r of result.removed) {
1522
+ process.stdout.write(`Removed ${r.kind} credential for org ${r.org}.
1523
+ `);
1524
+ }
1525
+ } catch (err) {
1526
+ if (err instanceof CredentialStoreUnavailableError) {
1527
+ process.stderr.write(`${err.message}
1528
+ `);
1529
+ process.exitCode = 4;
1530
+ return;
1531
+ }
1532
+ process.stderr.write(`Failed to remove credentials: ${err.message}
1533
+ `);
1534
+ process.exitCode = 1;
1535
+ }
1536
+ return;
1537
+ }
1538
+ const resolved = resolveOrg({ org: orgFromGlobal });
1539
+ if (!resolved) {
1540
+ process.stderr.write(`${formatResolutionError()}
1541
+ `);
1542
+ process.exitCode = 3;
1543
+ return;
1544
+ }
1545
+ try {
1546
+ const removed = await deletePat(resolved.org);
1547
+ if (removed) {
1548
+ process.stdout.write(`Credential removed for org ${resolved.org}.
1549
+ `);
1550
+ } else {
1551
+ process.stdout.write(`No stored credential for org ${resolved.org}.
1552
+ `);
1553
+ }
1554
+ } catch (err) {
1555
+ if (err instanceof CredentialStoreUnavailableError) {
1556
+ process.stderr.write(`${err.message}
1557
+ `);
1558
+ process.exitCode = 4;
1559
+ return;
1560
+ }
1561
+ throw err;
1562
+ }
1563
+ }
1564
+ function createAuthCommand() {
1565
+ const command = new Command3("auth");
1566
+ command.description(
1567
+ "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."
1568
+ );
1569
+ 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)");
1570
+ command.addHelpText(
1571
+ "after",
1572
+ `
1573
+ Default flow (OAuth, browser-based):
1574
+ azdo auth login --org <name>
1575
+ \u2192 opens the default browser for OAuth (Microsoft Entra v2 + PKCE).
1576
+
1577
+ Headless / no-browser:
1578
+ azdo auth login --org <name> --device-code
1579
+
1580
+ PAT path (legacy, opt-in):
1581
+ azdo auth login --org <name> --use-pat
1582
+ azdo auth --org <name> # back-compat alias of the above
1583
+
1584
+ OAuth scope set \u2014 shipped first-party client (default install):
1585
+ ${firstPartyShippedScopes().join("\n ")}
1586
+ (uses ${AZDO_RESOURCE_ID}/.default \u2014 per-scope consent is unavailable
1587
+ against a client we do not own; .default grants the VS client's
1588
+ pre-authorized AzDO permissions in one step.)
1589
+
1590
+ OAuth scope set \u2014 self-registered apps (--client-id / AZDO_OAUTH_CLIENT_ID):
1591
+ ${defaultScopes().join("\n ")}
1592
+ (FR-016, mirrors the PAT scope table \u2014 see docs/oauth-app-registration.md)
1593
+
1594
+ For self-registered OAuth apps (locked-down tenants), see docs/oauth-app-registration.md
1595
+ \u2014 that same guide is the maintainer reference for the project's shared client id.
1596
+
1597
+ Note: stored credentials may coexist as 'pat' or 'oauth' across orgs (FR-007).
1598
+ Note: \`azdo auth\` (no subcommand) preserves the legacy PAT-prompt entry point;
1599
+ \`azdo auth login\` is the spec-canonical name and defaults to OAuth.`
1600
+ ).action(async (options) => {
1601
+ await handleAuthRoot(options);
1602
+ });
1603
+ 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)");
1604
+ loginCmd.action(async () => {
1605
+ const merged = loginCmd.optsWithGlobals();
1606
+ await handleLoginSubcommand(merged);
1607
+ });
1608
+ const statusCmd = command.command("status").description("Report stored credentials (kind, org, account/expiry, backend) \u2014 never the token").option("--json", "emit JSON", false);
1609
+ statusCmd.action(async (options) => {
1610
+ const globals = statusCmd.optsWithGlobals();
1611
+ if (!globals.org) {
1612
+ if (options.json) {
1613
+ await handleStatusJson();
1614
+ return;
1615
+ }
1616
+ const report = await status();
1617
+ if (report.orgs.length === 0) {
1618
+ process.stdout.write("No stored credentials.\n");
1619
+ return;
1620
+ }
1621
+ for (const e of report.orgs) {
1622
+ const expiry = e.expiresAt ? new Date(e.expiresAt * 1e3).toISOString() : "n/a";
1623
+ process.stdout.write(
1624
+ `${e.org} ${e.kind} ${e.accountId ?? ""} ${expiry}
1625
+ `
1626
+ );
1627
+ }
1628
+ return;
1629
+ }
1630
+ const resolved = resolveOrg({ org: globals.org });
1631
+ if (!resolved) {
1632
+ process.stderr.write(`${formatResolutionError()}
1633
+ `);
1634
+ process.exitCode = 3;
1635
+ return;
1636
+ }
1637
+ await handleStatus(options, resolved.org);
1638
+ });
1639
+ statusCmd.addHelpText(
1640
+ "after",
1641
+ "\nStored credentials may be of kind `pat` or `oauth` and may coexist across orgs (FR-007).\n"
1642
+ );
1643
+ 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);
1644
+ logoutCmd.action(async (options) => {
1645
+ const globals = logoutCmd.optsWithGlobals();
1646
+ await handleLogout(options, globals.org);
1647
+ });
1648
+ return command;
1649
+ }
1650
+
1651
+ // src/commands/config.ts
1652
+ import { Command as Command4 } from "commander";
869
1653
  import { createInterface as createInterface2 } from "readline";
870
1654
  function formatConfigValue(value, unsetFallback = "") {
871
1655
  if (value === void 0) {
@@ -925,9 +1709,9 @@ async function promptForSetting(cfg, setting, ask) {
925
1709
  `);
926
1710
  }
927
1711
  function createConfigCommand() {
928
- const config = new Command3("config");
1712
+ const config = new Command4("config");
929
1713
  config.description("Manage CLI settings");
930
- const set = new Command3("set");
1714
+ const set = new Command4("set");
931
1715
  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) => {
932
1716
  try {
933
1717
  setConfigValue(key, value);
@@ -948,7 +1732,7 @@ function createConfigCommand() {
948
1732
  process.exit(1);
949
1733
  }
950
1734
  });
951
- const get = new Command3("get");
1735
+ const get = new Command4("get");
952
1736
  get.description("Get a configuration value").argument("<key>", "setting key (org, project, fields)").option("--json", "output in JSON format").action((key, options) => {
953
1737
  try {
954
1738
  const value = getConfigValue(key);
@@ -971,7 +1755,7 @@ function createConfigCommand() {
971
1755
  process.exit(1);
972
1756
  }
973
1757
  });
974
- const list = new Command3("list");
1758
+ const list = new Command4("list");
975
1759
  list.description("List all configuration values").option("--json", "output in JSON format").action((options) => {
976
1760
  const cfg = loadConfig();
977
1761
  if (options.json) {
@@ -980,7 +1764,7 @@ function createConfigCommand() {
980
1764
  }
981
1765
  writeConfigList(cfg);
982
1766
  });
983
- const unset = new Command3("unset");
1767
+ const unset = new Command4("unset");
984
1768
  unset.description("Remove a configuration value").argument("<key>", "setting key (org, project, fields)").option("--json", "output in JSON format").action((key, options) => {
985
1769
  try {
986
1770
  unsetConfigValue(key);
@@ -997,7 +1781,7 @@ function createConfigCommand() {
997
1781
  process.exit(1);
998
1782
  }
999
1783
  });
1000
- const wizard = new Command3("wizard");
1784
+ const wizard = new Command4("wizard");
1001
1785
  wizard.description("Interactive wizard to configure all settings").action(async () => {
1002
1786
  if (!process.stdin.isTTY) {
1003
1787
  process.stderr.write(
@@ -1028,9 +1812,9 @@ function createConfigCommand() {
1028
1812
  }
1029
1813
 
1030
1814
  // src/commands/set-state.ts
1031
- import { Command as Command4 } from "commander";
1815
+ import { Command as Command5 } from "commander";
1032
1816
  function createSetStateCommand() {
1033
- const command = new Command4("set-state");
1817
+ const command = new Command5("set-state");
1034
1818
  command.description("Change the state of a work item").argument("<id>", "work item ID").argument("<state>", 'target state (e.g., "Active", "Closed")').option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output result as JSON").action(
1035
1819
  async (idStr, state, options) => {
1036
1820
  const id = parseWorkItemId(idStr);
@@ -1038,11 +1822,11 @@ function createSetStateCommand() {
1038
1822
  let context;
1039
1823
  try {
1040
1824
  context = resolveContext(options);
1041
- const credential = await resolvePat();
1825
+ const credential = await requireAuthCredential(context.org);
1042
1826
  const operations = [
1043
1827
  { op: "add", path: "/fields/System.State", value: state }
1044
1828
  ];
1045
- const result = await updateWorkItem(context, id, credential.pat, "System.State", operations);
1829
+ const result = await updateWorkItem(context, id, credential, "System.State", operations);
1046
1830
  if (options.json) {
1047
1831
  process.stdout.write(
1048
1832
  JSON.stringify({
@@ -1066,9 +1850,9 @@ function createSetStateCommand() {
1066
1850
  }
1067
1851
 
1068
1852
  // src/commands/assign.ts
1069
- import { Command as Command5 } from "commander";
1853
+ import { Command as Command6 } from "commander";
1070
1854
  function createAssignCommand() {
1071
- const command = new Command5("assign");
1855
+ const command = new Command6("assign");
1072
1856
  command.description("Assign a work item to a user, or unassign it").argument("<id>", "work item ID").argument("[name]", "user display name or email").option("--unassign", "clear the Assigned To field").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output result as JSON").action(
1073
1857
  async (idStr, name, options) => {
1074
1858
  const id = parseWorkItemId(idStr);
@@ -1088,12 +1872,12 @@ function createAssignCommand() {
1088
1872
  let context;
1089
1873
  try {
1090
1874
  context = resolveContext(options);
1091
- const credential = await resolvePat();
1875
+ const credential = await requireAuthCredential(context.org);
1092
1876
  const value = options.unassign ? "" : name;
1093
1877
  const operations = [
1094
1878
  { op: "add", path: "/fields/System.AssignedTo", value }
1095
1879
  ];
1096
- const result = await updateWorkItem(context, id, credential.pat, "System.AssignedTo", operations);
1880
+ const result = await updateWorkItem(context, id, credential, "System.AssignedTo", operations);
1097
1881
  if (options.json) {
1098
1882
  process.stdout.write(
1099
1883
  JSON.stringify({
@@ -1118,9 +1902,9 @@ function createAssignCommand() {
1118
1902
  }
1119
1903
 
1120
1904
  // src/commands/set-field.ts
1121
- import { Command as Command6 } from "commander";
1905
+ import { Command as Command7 } from "commander";
1122
1906
  function createSetFieldCommand() {
1123
- const command = new Command6("set-field");
1907
+ const command = new Command7("set-field");
1124
1908
  command.description("Set any work item field by its reference name").argument("<id>", "work item ID").argument("<field>", "field reference name (e.g., System.Title)").argument("<value>", "new value for the field").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output result as JSON").action(
1125
1909
  async (idStr, field, value, options) => {
1126
1910
  const id = parseWorkItemId(idStr);
@@ -1128,11 +1912,11 @@ function createSetFieldCommand() {
1128
1912
  let context;
1129
1913
  try {
1130
1914
  context = resolveContext(options);
1131
- const credential = await resolvePat();
1915
+ const credential = await requireAuthCredential(context.org);
1132
1916
  const operations = [
1133
1917
  { op: "add", path: `/fields/${field}`, value }
1134
1918
  ];
1135
- const result = await updateWorkItem(context, id, credential.pat, field, operations);
1919
+ const result = await updateWorkItem(context, id, credential, field, operations);
1136
1920
  if (options.json) {
1137
1921
  process.stdout.write(
1138
1922
  JSON.stringify({
@@ -1156,9 +1940,9 @@ function createSetFieldCommand() {
1156
1940
  }
1157
1941
 
1158
1942
  // src/commands/get-md-field.ts
1159
- import { Command as Command7 } from "commander";
1943
+ import { Command as Command8 } from "commander";
1160
1944
  function createGetMdFieldCommand() {
1161
- const command = new Command7("get-md-field");
1945
+ const command = new Command8("get-md-field");
1162
1946
  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(
1163
1947
  async (idStr, field, options) => {
1164
1948
  const id = parseWorkItemId(idStr);
@@ -1166,8 +1950,8 @@ function createGetMdFieldCommand() {
1166
1950
  let context;
1167
1951
  try {
1168
1952
  context = resolveContext(options);
1169
- const credential = await resolvePat();
1170
- const value = await getWorkItemFieldValue(context, id, credential.pat, field);
1953
+ const credential = await requireAuthCredential(context.org);
1954
+ const value = await getWorkItemFieldValue(context, id, credential, field);
1171
1955
  if (value === null) {
1172
1956
  process.stdout.write("\n");
1173
1957
  } else {
@@ -1182,8 +1966,8 @@ function createGetMdFieldCommand() {
1182
1966
  }
1183
1967
 
1184
1968
  // src/commands/set-md-field.ts
1185
- import { existsSync, readFileSync as readFileSync2 } from "fs";
1186
- import { Command as Command8 } from "commander";
1969
+ import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
1970
+ import { Command as Command9 } from "commander";
1187
1971
  function fail(message) {
1188
1972
  process.stderr.write(`Error: ${message}
1189
1973
  `);
@@ -1202,11 +1986,11 @@ function resolveContent(inlineContent, options) {
1202
1986
  return null;
1203
1987
  }
1204
1988
  function readFileContent(filePath) {
1205
- if (!existsSync(filePath)) {
1989
+ if (!existsSync2(filePath)) {
1206
1990
  fail(`File not found: ${filePath}`);
1207
1991
  }
1208
1992
  try {
1209
- return readFileSync2(filePath, "utf-8");
1993
+ return readFileSync3(filePath, "utf-8");
1210
1994
  } catch {
1211
1995
  fail(`Cannot read file: ${filePath}`);
1212
1996
  }
@@ -1245,7 +2029,7 @@ function formatOutput(result, options, field) {
1245
2029
  }
1246
2030
  }
1247
2031
  function createSetMdFieldCommand() {
1248
- const command = new Command8("set-md-field");
2032
+ const command = new Command9("set-md-field");
1249
2033
  command.description("Set a work item field with markdown content").argument("<id>", "work item ID").argument("<field>", "field reference name (e.g., System.Description)").argument("[content]", "markdown content to set").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output result as JSON").option("--file <path>", "read markdown content from file").action(
1250
2034
  async (idStr, field, inlineContent, options) => {
1251
2035
  const id = parseWorkItemId(idStr);
@@ -1254,12 +2038,12 @@ function createSetMdFieldCommand() {
1254
2038
  let context;
1255
2039
  try {
1256
2040
  context = resolveContext(options);
1257
- const credential = await resolvePat();
2041
+ const credential = await requireAuthCredential(context.org);
1258
2042
  const operations = [
1259
2043
  { op: "add", path: `/fields/${field}`, value: content },
1260
2044
  { op: "add", path: `/multilineFieldsFormat/${field}`, value: "Markdown" }
1261
2045
  ];
1262
- const result = await updateWorkItem(context, id, credential.pat, field, operations);
2046
+ const result = await updateWorkItem(context, id, credential, field, operations);
1263
2047
  formatOutput(result, options, field);
1264
2048
  } catch (err) {
1265
2049
  handleCommandError(err, id, context, "write");
@@ -1270,8 +2054,8 @@ function createSetMdFieldCommand() {
1270
2054
  }
1271
2055
 
1272
2056
  // src/commands/upsert.ts
1273
- import { existsSync as existsSync2, readFileSync as readFileSync3, unlinkSync } from "fs";
1274
- import { Command as Command9 } from "commander";
2057
+ import { existsSync as existsSync3, readFileSync as readFileSync4, unlinkSync } from "fs";
2058
+ import { Command as Command10 } from "commander";
1275
2059
 
1276
2060
  // src/services/task-document.ts
1277
2061
  var FIELD_ALIASES = /* @__PURE__ */ new Map([
@@ -1434,12 +2218,12 @@ function loadSourceContent(options) {
1434
2218
  return { content: options.content };
1435
2219
  }
1436
2220
  const filePath = options.file;
1437
- if (!existsSync2(filePath)) {
2221
+ if (!existsSync3(filePath)) {
1438
2222
  fail2(`File not found: ${filePath}`);
1439
2223
  }
1440
2224
  try {
1441
2225
  return {
1442
- content: readFileSync3(filePath, "utf-8"),
2226
+ content: readFileSync4(filePath, "utf-8"),
1443
2227
  sourceFile: filePath
1444
2228
  };
1445
2229
  } catch {
@@ -1555,7 +2339,7 @@ function handleUpsertError(err, id, context) {
1555
2339
  process.exit(1);
1556
2340
  }
1557
2341
  function createUpsertCommand() {
1558
- const command = new Command9("upsert");
2342
+ const command = new Command10("upsert");
1559
2343
  command.description("Create or update a work item from a markdown document").argument("[id]", "work item ID to update; omit to create a new work item").option("--content <markdown>", "task document content").option("--file <path>", "read task document from file").option("--type <workItemType>", "create mode work item type (defaults to Task)").option("--json", "output result as JSON").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").action(async (idStr, options) => {
1560
2344
  validateOrgProjectPair(options);
1561
2345
  const id = idStr === void 0 ? void 0 : parseWorkItemId(idStr);
@@ -1570,15 +2354,15 @@ function createUpsertCommand() {
1570
2354
  ensureTitleForCreate(document.fields);
1571
2355
  }
1572
2356
  const operations = toPatchOperations(document.fields, action);
1573
- const credential = await resolvePat();
2357
+ const credential = await requireAuthCredential(context.org);
1574
2358
  let writeResult;
1575
2359
  if (action === "created") {
1576
- writeResult = await createWorkItem(context, createType, credential.pat, operations);
2360
+ writeResult = await createWorkItem(context, createType, credential, operations);
1577
2361
  } else {
1578
2362
  if (id === void 0) {
1579
2363
  fail2("Work item ID is required for updates.");
1580
2364
  }
1581
- writeResult = await applyWorkItemPatch(context, id, credential.pat, operations);
2365
+ writeResult = await applyWorkItemPatch(context, id, credential, operations);
1582
2366
  }
1583
2367
  const result = buildUpsertResult(
1584
2368
  action,
@@ -1596,7 +2380,7 @@ function createUpsertCommand() {
1596
2380
  }
1597
2381
 
1598
2382
  // src/commands/list-fields.ts
1599
- import { Command as Command10 } from "commander";
2383
+ import { Command as Command11 } from "commander";
1600
2384
  function stringifyValue(value) {
1601
2385
  if (value === null || value === void 0) return "";
1602
2386
  if (typeof value === "object") return JSON.stringify(value);
@@ -1628,7 +2412,7 @@ function formatFieldList(fields) {
1628
2412
  }).join("\n");
1629
2413
  }
1630
2414
  function createListFieldsCommand() {
1631
- const command = new Command10("list-fields");
2415
+ const command = new Command11("list-fields");
1632
2416
  command.description("List all fields of an Azure DevOps work item").argument("<id>", "work item ID").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output result as JSON").action(
1633
2417
  async (idStr, options) => {
1634
2418
  const id = parseWorkItemId(idStr);
@@ -1636,8 +2420,8 @@ function createListFieldsCommand() {
1636
2420
  let context;
1637
2421
  try {
1638
2422
  context = resolveContext(options);
1639
- const credential = await resolvePat();
1640
- const fields = await getWorkItemFields(context, id, credential.pat);
2423
+ const credential = await requireAuthCredential(context.org);
2424
+ const fields = await getWorkItemFields(context, id, credential);
1641
2425
  if (options.json) {
1642
2426
  process.stdout.write(JSON.stringify({ id, fields }, null, 2) + "\n");
1643
2427
  } else {
@@ -1655,7 +2439,7 @@ function createListFieldsCommand() {
1655
2439
  }
1656
2440
 
1657
2441
  // src/commands/pr.ts
1658
- import { Command as Command11 } from "commander";
2442
+ import { Command as Command12 } from "commander";
1659
2443
 
1660
2444
  // src/services/pr-client.ts
1661
2445
  function buildPullRequestsUrl(context, repo, sourceBranch, opts) {
@@ -1672,6 +2456,13 @@ function buildPullRequestsUrl(context, repo, sourceBranch, opts) {
1672
2456
  }
1673
2457
  return url;
1674
2458
  }
2459
+ function buildPullRequestStatusesUrl(context, repo, prId) {
2460
+ const url = new URL(
2461
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}/statuses`
2462
+ );
2463
+ url.searchParams.set("api-version", "7.1");
2464
+ return url;
2465
+ }
1675
2466
  function mapPullRequest(repo, pullRequest) {
1676
2467
  return {
1677
2468
  id: pullRequest.pullRequestId,
@@ -1681,7 +2472,36 @@ function mapPullRequest(repo, pullRequest) {
1681
2472
  targetRefName: pullRequest.targetRefName,
1682
2473
  status: pullRequest.status,
1683
2474
  createdBy: pullRequest.createdBy?.displayName ?? null,
1684
- url: pullRequest._links.web.href
2475
+ url: pullRequest._links?.web?.href ?? null
2476
+ };
2477
+ }
2478
+ function mapPullRequestCheckName(status2) {
2479
+ const genre = status2.context?.genre?.trim();
2480
+ const name = status2.context?.name?.trim();
2481
+ if (genre && name) {
2482
+ return `${genre}/${name}`;
2483
+ }
2484
+ if (name) {
2485
+ return name;
2486
+ }
2487
+ if (genre) {
2488
+ return genre;
2489
+ }
2490
+ return `Status #${status2.id}`;
2491
+ }
2492
+ function mapPullRequestCheck(status2) {
2493
+ if (status2.state === "notApplicable" || status2.state === "notSet") {
2494
+ return null;
2495
+ }
2496
+ return {
2497
+ id: status2.id,
2498
+ state: status2.state,
2499
+ name: mapPullRequestCheckName(status2),
2500
+ description: status2.description ?? null,
2501
+ targetUrl: status2.targetUrl ?? null,
2502
+ createdBy: status2.createdBy?.displayName ?? null,
2503
+ createdAt: status2.creationDate ?? null,
2504
+ updatedAt: status2.updatedDate ?? null
1685
2505
  };
1686
2506
  }
1687
2507
  function mapComment(comment) {
@@ -1697,9 +2517,6 @@ function mapComment(comment) {
1697
2517
  };
1698
2518
  }
1699
2519
  function mapThread(thread) {
1700
- if (thread.status !== "active" && thread.status !== "pending") {
1701
- return null;
1702
- }
1703
2520
  const comments = thread.comments.map(mapComment).filter((comment) => comment !== null);
1704
2521
  if (comments.length === 0) {
1705
2522
  return null;
@@ -1711,22 +2528,67 @@ function mapThread(thread) {
1711
2528
  comments
1712
2529
  };
1713
2530
  }
2531
+ function toActiveCommentThread(thread) {
2532
+ return {
2533
+ id: thread.id,
2534
+ status: thread.status,
2535
+ threadContext: thread.threadContext?.filePath ?? null,
2536
+ comments: thread.comments.map(mapComment).filter((comment) => comment !== null)
2537
+ };
2538
+ }
2539
+ var RESOLVED_THREAD_STATUSES = /* @__PURE__ */ new Set(["fixed", "wontFix", "closed", "byDesign"]);
2540
+ function isThreadResolved(status2) {
2541
+ return RESOLVED_THREAD_STATUSES.has(status2);
2542
+ }
1714
2543
  async function readJsonResponse(response) {
1715
2544
  if (!response.ok) {
1716
2545
  throw new Error(`HTTP_${response.status}`);
1717
2546
  }
1718
2547
  return response.json();
1719
2548
  }
1720
- async function listPullRequests(context, repo, pat, sourceBranch, opts) {
2549
+ async function patchThreadStatus(context, repo, cred, prId, threadId, status2) {
2550
+ const url = new URL(
2551
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}/threads/${threadId}`
2552
+ );
2553
+ url.searchParams.set("api-version", "7.1");
2554
+ const response = await fetchWithErrors(url.toString(), {
2555
+ method: "PATCH",
2556
+ headers: {
2557
+ ...authHeaders(cred),
2558
+ "Content-Type": "application/json"
2559
+ },
2560
+ body: JSON.stringify({ status: status2 })
2561
+ });
2562
+ const data = await readJsonResponse(response);
2563
+ return toActiveCommentThread(data);
2564
+ }
2565
+ async function getPullRequestById(context, repo, cred, prId) {
2566
+ const url = new URL(
2567
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}`
2568
+ );
2569
+ url.searchParams.set("api-version", "7.1");
2570
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
2571
+ const data = await readJsonResponse(response);
2572
+ return mapPullRequest(repo, data);
2573
+ }
2574
+ async function listPullRequests(context, repo, cred, sourceBranch, opts) {
1721
2575
  const response = await fetchWithErrors(
1722
2576
  buildPullRequestsUrl(context, repo, sourceBranch, opts).toString(),
1723
- { headers: authHeaders(pat) }
2577
+ { headers: authHeaders(cred) }
1724
2578
  );
1725
2579
  const data = await readJsonResponse(response);
1726
2580
  return data.value.map((pullRequest) => mapPullRequest(repo, pullRequest));
1727
2581
  }
1728
- async function openPullRequest(context, repo, pat, sourceBranch, title, description) {
1729
- const existing = await listPullRequests(context, repo, pat, sourceBranch, {
2582
+ async function getPullRequestChecks(context, repo, cred, prId) {
2583
+ const response = await fetchWithErrors(
2584
+ buildPullRequestStatusesUrl(context, repo, prId).toString(),
2585
+ { headers: authHeaders(cred) }
2586
+ );
2587
+ const data = await readJsonResponse(response);
2588
+ return data.value.map(mapPullRequestCheck).filter((check) => check !== null);
2589
+ }
2590
+ async function openPullRequest(context, repo, cred, sourceBranch, title, description) {
2591
+ const existing = await listPullRequests(context, repo, cred, sourceBranch, {
1730
2592
  status: "active",
1731
2593
  targetBranch: "develop"
1732
2594
  });
@@ -1754,7 +2616,7 @@ async function openPullRequest(context, repo, pat, sourceBranch, title, descript
1754
2616
  const response = await fetchWithErrors(url.toString(), {
1755
2617
  method: "POST",
1756
2618
  headers: {
1757
- ...authHeaders(pat),
2619
+ ...authHeaders(cred),
1758
2620
  "Content-Type": "application/json"
1759
2621
  },
1760
2622
  body: JSON.stringify(payload)
@@ -1767,96 +2629,147 @@ async function openPullRequest(context, repo, pat, sourceBranch, title, descript
1767
2629
  pullRequest: mapPullRequest(repo, data)
1768
2630
  };
1769
2631
  }
1770
- async function getPullRequestThreads(context, repo, pat, prId) {
2632
+ async function getPullRequestThreads(context, repo, cred, prId) {
1771
2633
  const url = new URL(
1772
2634
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}/threads`
1773
2635
  );
1774
2636
  url.searchParams.set("api-version", "7.1");
1775
- const response = await fetchWithErrors(url.toString(), { headers: authHeaders(pat) });
2637
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
1776
2638
  const data = await readJsonResponse(response);
1777
2639
  return data.value.map(mapThread).filter((thread) => thread !== null);
1778
2640
  }
1779
2641
 
1780
2642
  // src/commands/pr.ts
2643
+ function parsePositivePrNumber(raw) {
2644
+ if (!/^\d+$/.test(raw)) {
2645
+ return null;
2646
+ }
2647
+ const n = Number.parseInt(raw, 10);
2648
+ return Number.isFinite(n) && n > 0 ? n : null;
2649
+ }
2650
+ 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.";
2651
+ function configureUnwrappedHelp(command) {
2652
+ return command.configureHelp({ helpWidth: 1e3 });
2653
+ }
2654
+ function autoDetectZeroMatch(branch) {
2655
+ 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.`;
2656
+ }
2657
+ function autoDetectMultiMatch(branch, ids) {
2658
+ return `Multiple open pull requests match branch ${branch}: ${ids.map((id) => `#${id}`).join(", ")}. Re-run with --pr-number to choose.`;
2659
+ }
2660
+ function writeContractError(line) {
2661
+ process.stderr.write(`${line}
2662
+ `);
2663
+ process.exitCode = 1;
2664
+ }
1781
2665
  function formatBranchName(refName) {
1782
2666
  return refName.startsWith("refs/heads/") ? refName.slice("refs/heads/".length) : refName;
1783
2667
  }
1784
2668
  function writeError(message) {
1785
2669
  process.stderr.write(`Error: ${message}
1786
2670
  `);
1787
- process.exit(1);
2671
+ process.exitCode = 1;
1788
2672
  }
1789
2673
  function handlePrCommandError(err, context, mode = "read") {
1790
2674
  const error = err instanceof Error ? err : new Error(String(err));
1791
2675
  if (error.message === "AUTH_FAILED") {
1792
2676
  const scopeLabel = mode === "write" ? "Code (Read & Write)" : "Code (Read)";
1793
2677
  writeError(`Authentication failed. Check that your PAT is valid and has the "${scopeLabel}" scope.`);
2678
+ return;
1794
2679
  }
1795
2680
  if (error.message === "PERMISSION_DENIED") {
1796
2681
  writeError(`Access denied. Your PAT may lack ${mode} permissions for project "${context?.project}".`);
2682
+ return;
1797
2683
  }
1798
2684
  if (error.message === "NETWORK_ERROR") {
1799
2685
  writeError("Could not connect to Azure DevOps. Check your network connection.");
2686
+ return;
1800
2687
  }
1801
2688
  if (error.message.startsWith("NOT_FOUND")) {
1802
2689
  writeError(`Azure DevOps repository not found in ${context?.org}/${context?.project}.`);
2690
+ return;
1803
2691
  }
1804
2692
  if (error.message.startsWith("HTTP_")) {
1805
2693
  writeError(`Azure DevOps request failed with ${error.message}.`);
2694
+ return;
1806
2695
  }
1807
2696
  writeError(error.message);
1808
2697
  }
2698
+ function formatPullRequestChecks(checks) {
2699
+ if (checks.length === 0) {
2700
+ return ["Checks: none reported by Azure DevOps"];
2701
+ }
2702
+ const lines = ["Checks:"];
2703
+ for (const check of checks) {
2704
+ lines.push(`- [${check.state}] ${check.name}`);
2705
+ if ((check.state === "failed" || check.state === "error") && check.description) {
2706
+ lines.push(` Detail: ${check.description}`);
2707
+ }
2708
+ }
2709
+ return lines;
2710
+ }
1809
2711
  function formatPullRequestBlock(pullRequest) {
1810
2712
  return [
1811
2713
  `#${pullRequest.id} [${pullRequest.status}] ${pullRequest.title}`,
1812
2714
  `${formatBranchName(pullRequest.sourceRefName)} -> ${formatBranchName(pullRequest.targetRefName)}`,
1813
- pullRequest.url
2715
+ pullRequest.url ?? "\u2014",
2716
+ ...formatPullRequestChecks(pullRequest.checks)
1814
2717
  ].join("\n");
1815
2718
  }
2719
+ function threadStatusLabel(status2) {
2720
+ return isThreadResolved(status2) ? "resolved" : status2;
2721
+ }
1816
2722
  function formatThreads(prId, title, threads) {
1817
- const lines = [`Active comments for pull request #${prId}: ${title}`];
2723
+ const lines = [`Comment threads for pull request #${prId}: ${title}`];
1818
2724
  for (const thread of threads) {
1819
- lines.push("", `Thread #${thread.id} [${thread.status}] ${thread.threadContext ?? "(general)"}`);
2725
+ lines.push("", `Thread #${thread.id} [${threadStatusLabel(thread.status)}] ${thread.threadContext ?? "(general)"}`);
1820
2726
  for (const comment of thread.comments) {
1821
2727
  lines.push(` ${comment.author ?? "Unknown"}: ${comment.content}`);
1822
2728
  }
1823
2729
  }
1824
2730
  return lines.join("\n");
1825
2731
  }
1826
- async function resolvePrCommandContext(options) {
2732
+ async function resolvePrCommandContext(options, resolveOpts = {}) {
2733
+ const requireBranch = resolveOpts.requireBranch ?? true;
1827
2734
  const context = resolveContext(options);
1828
2735
  const repo = detectRepoName();
1829
- const branch = getCurrentBranch();
1830
- const credential = await resolvePat();
2736
+ const branch = requireBranch ? getCurrentBranch() : null;
2737
+ const credential = await requireAuthCredential(context.org);
1831
2738
  return {
1832
2739
  context,
1833
2740
  repo,
1834
2741
  branch,
1835
- pat: credential.pat
2742
+ pat: credential
1836
2743
  };
1837
2744
  }
1838
2745
  function createPrStatusCommand() {
1839
- const command = new Command11("status");
2746
+ const command = new Command12("status");
1840
2747
  command.description("Check pull requests for the current branch").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output JSON").action(async (options) => {
1841
2748
  validateOrgProjectPair(options);
1842
2749
  let context;
1843
2750
  try {
1844
2751
  const resolved = await resolvePrCommandContext(options);
1845
2752
  context = resolved.context;
1846
- const pullRequests = await listPullRequests(resolved.context, resolved.repo, resolved.pat, resolved.branch);
1847
- const { branch, repo } = resolved;
1848
- const result = { branch, repository: repo, pullRequests };
2753
+ const branch = resolved.branch;
2754
+ const pullRequests = await listPullRequests(resolved.context, resolved.repo, resolved.pat, branch);
2755
+ const pullRequestsWithChecks = await Promise.all(
2756
+ pullRequests.map(async (pullRequest) => ({
2757
+ ...pullRequest,
2758
+ checks: await getPullRequestChecks(resolved.context, resolved.repo, resolved.pat, pullRequest.id)
2759
+ }))
2760
+ );
2761
+ const result = { branch, repository: resolved.repo, pullRequests: pullRequestsWithChecks };
1849
2762
  if (options.json) {
1850
2763
  process.stdout.write(`${JSON.stringify(result, null, 2)}
1851
2764
  `);
1852
2765
  return;
1853
2766
  }
1854
- if (pullRequests.length === 0) {
2767
+ if (pullRequestsWithChecks.length === 0) {
1855
2768
  process.stdout.write(`No pull requests found for branch ${branch}.
1856
2769
  `);
1857
2770
  return;
1858
2771
  }
1859
- process.stdout.write(`${pullRequests.map(formatPullRequestBlock).join("\n\n")}
2772
+ process.stdout.write(`${pullRequestsWithChecks.map(formatPullRequestBlock).join("\n\n")}
1860
2773
  `);
1861
2774
  } catch (err) {
1862
2775
  handlePrCommandError(err, context, "read");
@@ -1865,16 +2778,18 @@ function createPrStatusCommand() {
1865
2778
  return command;
1866
2779
  }
1867
2780
  function createPrOpenCommand() {
1868
- const command = new Command11("open");
2781
+ const command = new Command12("open");
1869
2782
  command.description("Open a pull request from the current branch to develop").option("--title <title>", "pull request title").option("--description <description>", "pull request description").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output JSON").action(async (options) => {
1870
2783
  validateOrgProjectPair(options);
1871
2784
  const title = options.title?.trim();
1872
2785
  if (!title) {
1873
2786
  writeError("--title is required for pull request creation.");
2787
+ return;
1874
2788
  }
1875
2789
  const description = options.description?.trim();
1876
2790
  if (!description) {
1877
2791
  writeError("--description is required for pull request creation.");
2792
+ return;
1878
2793
  }
1879
2794
  let context;
1880
2795
  try {
@@ -1882,12 +2797,14 @@ function createPrOpenCommand() {
1882
2797
  context = resolved.context;
1883
2798
  if (resolved.branch === "develop") {
1884
2799
  writeError("Pull request creation requires a source branch other than develop.");
2800
+ return;
1885
2801
  }
2802
+ const openBranch = resolved.branch;
1886
2803
  const result = await openPullRequest(
1887
2804
  resolved.context,
1888
2805
  resolved.repo,
1889
2806
  resolved.pat,
1890
- resolved.branch,
2807
+ openBranch,
1891
2808
  title,
1892
2809
  description
1893
2810
  );
@@ -1898,19 +2815,20 @@ function createPrOpenCommand() {
1898
2815
  }
1899
2816
  if (result.created) {
1900
2817
  process.stdout.write(`Created pull request #${result.pullRequest.id}: ${result.pullRequest.title}
1901
- ${result.pullRequest.url}
2818
+ ${result.pullRequest.url ?? "\u2014"}
1902
2819
  `);
1903
2820
  return;
1904
2821
  }
1905
2822
  process.stdout.write(
1906
2823
  `Active pull request already exists for ${resolved.branch} -> develop: #${result.pullRequest.id}
1907
- ${result.pullRequest.url}
2824
+ ${result.pullRequest.url ?? "\u2014"}
1908
2825
  `
1909
2826
  );
1910
2827
  } catch (err) {
1911
2828
  if (err instanceof Error && err.message.startsWith("AMBIGUOUS_PRS:")) {
1912
2829
  const ids = err.message.replace("AMBIGUOUS_PRS:", "").split(",").map((id) => `#${id}`).join(", ");
1913
2830
  writeError(`Multiple active pull requests already exist for this branch targeting develop: ${ids}. Use pr status to review them.`);
2831
+ return;
1914
2832
  }
1915
2833
  handlePrCommandError(err, context, "write");
1916
2834
  }
@@ -1918,34 +2836,65 @@ ${result.pullRequest.url}
1918
2836
  return command;
1919
2837
  }
1920
2838
  function createPrCommentsCommand() {
1921
- const command = new Command11("comments");
1922
- command.description("List active pull request comments for the current branch").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output JSON").action(async (options) => {
2839
+ const command = new Command12("comments");
2840
+ 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("--json", "output JSON").action(async (options) => {
1923
2841
  validateOrgProjectPair(options);
1924
2842
  let context;
2843
+ let explicitPrId = null;
2844
+ if (options.prNumber !== void 0) {
2845
+ explicitPrId = parsePositivePrNumber(options.prNumber);
2846
+ if (explicitPrId === null) {
2847
+ writeError(`Invalid --pr-number "${options.prNumber}"; expected a positive integer.`);
2848
+ return;
2849
+ }
2850
+ }
1925
2851
  try {
1926
- const resolved = await resolvePrCommandContext(options);
2852
+ const resolved = await resolvePrCommandContext(options, { requireBranch: explicitPrId === null });
1927
2853
  context = resolved.context;
1928
- const pullRequests = await listPullRequests(resolved.context, resolved.repo, resolved.pat, resolved.branch, {
1929
- status: "active"
1930
- });
1931
- if (pullRequests.length === 0) {
1932
- writeError(`No active pull request found for branch ${resolved.branch}.`);
1933
- }
1934
- if (pullRequests.length > 1) {
1935
- const ids = pullRequests.map((pullRequest2) => `#${pullRequest2.id}`).join(", ");
1936
- writeError(`Multiple active pull requests found for branch ${resolved.branch}: ${ids}. Use pr status to review them.`);
2854
+ let pullRequest;
2855
+ let branchLabel;
2856
+ if (explicitPrId !== null) {
2857
+ try {
2858
+ pullRequest = await getPullRequestById(resolved.context, resolved.repo, resolved.pat, explicitPrId);
2859
+ } catch (err) {
2860
+ if (err instanceof Error && err.message.startsWith("NOT_FOUND")) {
2861
+ writeError(`Pull request #${explicitPrId} not found in ${resolved.context.org}/${resolved.context.project}/${resolved.repo}.`);
2862
+ return;
2863
+ }
2864
+ throw err;
2865
+ }
2866
+ branchLabel = resolved.branch ?? pullRequest.sourceRefName;
2867
+ } else {
2868
+ const pullRequests = await listPullRequests(resolved.context, resolved.repo, resolved.pat, resolved.branch, {
2869
+ status: "active"
2870
+ });
2871
+ if (pullRequests.length === 0) {
2872
+ writeContractError(autoDetectZeroMatch(resolved.branch));
2873
+ return;
2874
+ }
2875
+ if (pullRequests.length > 1) {
2876
+ writeContractError(autoDetectMultiMatch(resolved.branch, pullRequests.map((pr) => pr.id)));
2877
+ return;
2878
+ }
2879
+ pullRequest = pullRequests[0];
2880
+ branchLabel = resolved.branch;
1937
2881
  }
1938
- const pullRequest = pullRequests[0];
1939
- const threads = await getPullRequestThreads(resolved.context, resolved.repo, resolved.pat, pullRequest.id);
1940
- const result = { branch: resolved.branch, pullRequest, threads };
2882
+ const allThreads = await getPullRequestThreads(resolved.context, resolved.repo, resolved.pat, pullRequest.id);
2883
+ const threads = options.hideResolved ? allThreads.filter((thread) => !isThreadResolved(thread.status)) : allThreads;
2884
+ const result = { branch: branchLabel, pullRequest, threads };
1941
2885
  if (options.json) {
1942
2886
  process.stdout.write(`${JSON.stringify(result, null, 2)}
1943
2887
  `);
1944
2888
  return;
1945
2889
  }
1946
2890
  if (threads.length === 0) {
1947
- process.stdout.write(`Pull request #${pullRequest.id} has no active comments.
2891
+ if (options.hideResolved && allThreads.length > 0) {
2892
+ process.stdout.write(`Pull request #${pullRequest.id} has no unresolved comment threads (${allThreads.length} resolved thread${allThreads.length === 1 ? "" : "s"} hidden by --hide-resolved).
1948
2893
  `);
2894
+ } else {
2895
+ process.stdout.write(`Pull request #${pullRequest.id} has no comment threads.
2896
+ `);
2897
+ }
1949
2898
  return;
1950
2899
  }
1951
2900
  process.stdout.write(`${formatThreads(pullRequest.id, pullRequest.title, threads)}
@@ -1956,17 +2905,135 @@ function createPrCommentsCommand() {
1956
2905
  });
1957
2906
  return command;
1958
2907
  }
2908
+ async function resolveThreadTarget(threadIdRaw, options) {
2909
+ validateOrgProjectPair(options);
2910
+ const threadId = parsePositivePrNumber(threadIdRaw);
2911
+ if (threadId === null) {
2912
+ writeError(`Invalid thread id "${threadIdRaw}"; expected a positive integer.`);
2913
+ return null;
2914
+ }
2915
+ let explicitPrId = null;
2916
+ if (options.prNumber !== void 0) {
2917
+ explicitPrId = parsePositivePrNumber(options.prNumber);
2918
+ if (explicitPrId === null) {
2919
+ writeError(`Invalid --pr-number "${options.prNumber}"; expected a positive integer.`);
2920
+ return null;
2921
+ }
2922
+ }
2923
+ const resolved = await resolvePrCommandContext(options, { requireBranch: explicitPrId === null });
2924
+ let pullRequest;
2925
+ if (explicitPrId !== null) {
2926
+ try {
2927
+ pullRequest = await getPullRequestById(resolved.context, resolved.repo, resolved.pat, explicitPrId);
2928
+ } catch (err) {
2929
+ if (err instanceof Error && err.message.startsWith("NOT_FOUND")) {
2930
+ writeError(`Pull request #${explicitPrId} not found in ${resolved.context.org}/${resolved.context.project}/${resolved.repo}.`);
2931
+ return null;
2932
+ }
2933
+ throw err;
2934
+ }
2935
+ } else {
2936
+ const pullRequests = await listPullRequests(resolved.context, resolved.repo, resolved.pat, resolved.branch, {
2937
+ status: "active"
2938
+ });
2939
+ if (pullRequests.length === 0) {
2940
+ writeContractError(autoDetectZeroMatch(resolved.branch));
2941
+ return null;
2942
+ }
2943
+ if (pullRequests.length > 1) {
2944
+ writeContractError(autoDetectMultiMatch(resolved.branch, pullRequests.map((pr) => pr.id)));
2945
+ return null;
2946
+ }
2947
+ pullRequest = pullRequests[0];
2948
+ }
2949
+ return { context: resolved.context, repo: resolved.repo, pat: resolved.pat, pullRequest, threadId };
2950
+ }
2951
+ async function runThreadStateChange(threadIdRaw, options, direction) {
2952
+ let context;
2953
+ try {
2954
+ const target = await resolveThreadTarget(threadIdRaw, options);
2955
+ if (target === null) {
2956
+ return;
2957
+ }
2958
+ context = target.context;
2959
+ const threads = await getPullRequestThreads(target.context, target.repo, target.pat, target.pullRequest.id);
2960
+ const thread = threads.find((t) => t.id === target.threadId);
2961
+ if (!thread) {
2962
+ writeError(`Thread #${target.threadId} not found on pull request #${target.pullRequest.id}.`);
2963
+ return;
2964
+ }
2965
+ const alreadyInTargetState = direction === "resolve" ? isThreadResolved(thread.status) : !isThreadResolved(thread.status);
2966
+ const targetStatus = direction === "resolve" ? "fixed" : "active";
2967
+ if (alreadyInTargetState) {
2968
+ const humanLabel = direction === "resolve" ? "resolved" : "active";
2969
+ const noopResult = {
2970
+ pullRequestId: target.pullRequest.id,
2971
+ threadId: target.threadId,
2972
+ status: thread.status,
2973
+ noop: true
2974
+ };
2975
+ if (options.json) {
2976
+ process.stdout.write(`${JSON.stringify(noopResult, null, 2)}
2977
+ `);
2978
+ return;
2979
+ }
2980
+ process.stdout.write(`Thread #${target.threadId} is already ${humanLabel} on pull request #${target.pullRequest.id} (current status: ${thread.status}).
2981
+ `);
2982
+ return;
2983
+ }
2984
+ const updated = await patchThreadStatus(
2985
+ target.context,
2986
+ target.repo,
2987
+ target.pat,
2988
+ target.pullRequest.id,
2989
+ target.threadId,
2990
+ targetStatus
2991
+ );
2992
+ const result = {
2993
+ pullRequestId: target.pullRequest.id,
2994
+ threadId: target.threadId,
2995
+ status: targetStatus,
2996
+ noop: false
2997
+ };
2998
+ if (options.json) {
2999
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
3000
+ `);
3001
+ return;
3002
+ }
3003
+ const verb = direction === "resolve" ? "resolved" : "reopened";
3004
+ process.stdout.write(`Thread #${target.threadId} ${verb} on pull request #${target.pullRequest.id} (status: ${updated.status}).
3005
+ `);
3006
+ } catch (err) {
3007
+ handlePrCommandError(err, context, "write");
3008
+ }
3009
+ }
3010
+ function createPrCommentResolveCommand() {
3011
+ const command = new Command12("comment-resolve");
3012
+ 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) => {
3013
+ await runThreadStateChange(threadIdRaw, options, "resolve");
3014
+ });
3015
+ return command;
3016
+ }
3017
+ function createPrCommentReopenCommand() {
3018
+ const command = new Command12("comment-reopen");
3019
+ 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) => {
3020
+ await runThreadStateChange(threadIdRaw, options, "reopen");
3021
+ });
3022
+ return command;
3023
+ }
1959
3024
  function createPrCommand() {
1960
- const command = new Command11("pr");
3025
+ const command = new Command12("pr");
1961
3026
  command.description("Manage Azure DevOps pull requests");
1962
3027
  command.addCommand(createPrStatusCommand());
1963
3028
  command.addCommand(createPrOpenCommand());
1964
3029
  command.addCommand(createPrCommentsCommand());
3030
+ command.addCommand(createPrCommentResolveCommand());
3031
+ command.addCommand(createPrCommentReopenCommand());
1965
3032
  return command;
1966
3033
  }
1967
3034
 
1968
3035
  // src/commands/comments.ts
1969
- import { Command as Command12 } from "commander";
3036
+ import { Command as Command13 } from "commander";
1970
3037
  function writeError2(message) {
1971
3038
  process.stderr.write(`Error: ${message}
1972
3039
  `);
@@ -1977,23 +3044,24 @@ function formatCommentHeader(comment) {
1977
3044
  const timestamp = comment.modifiedAt ?? comment.createdAt ?? "Unknown time";
1978
3045
  return `Comment #${comment.id} by ${author} at ${timestamp}`;
1979
3046
  }
1980
- function formatComments(result) {
3047
+ function formatComments(result, convertMarkdown) {
1981
3048
  const lines = [`Comments for work item #${result.workItemId}`];
1982
3049
  for (const comment of result.comments) {
1983
- lines.push("", formatCommentHeader(comment), comment.text);
3050
+ const text = convertMarkdown ? toMarkdown(comment.text) : comment.text;
3051
+ lines.push("", formatCommentHeader(comment), text);
1984
3052
  }
1985
3053
  return lines.join("\n");
1986
3054
  }
1987
3055
  function createCommentsListCommand() {
1988
- const command = new Command12("list");
1989
- 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").action(async (idStr, options) => {
3056
+ const command = new Command13("list");
3057
+ 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) => {
1990
3058
  validateOrgProjectPair(options);
1991
3059
  const id = parseWorkItemId(idStr);
1992
3060
  let context;
1993
3061
  try {
1994
3062
  context = resolveContext(options);
1995
- const credential = await resolvePat();
1996
- const result = await listWorkItemComments(context, id, credential.pat);
3063
+ const credential = await requireAuthCredential(context.org);
3064
+ const result = await listWorkItemComments(context, id, credential);
1997
3065
  if (options.json) {
1998
3066
  process.stdout.write(`${JSON.stringify(result, null, 2)}
1999
3067
  `);
@@ -2004,7 +3072,7 @@ function createCommentsListCommand() {
2004
3072
  `);
2005
3073
  return;
2006
3074
  }
2007
- process.stdout.write(`${formatComments(result)}
3075
+ process.stdout.write(`${formatComments(result, options.markdown === true)}
2008
3076
  `);
2009
3077
  } catch (err) {
2010
3078
  handleCommandError(err, id, context, "read");
@@ -2013,8 +3081,8 @@ function createCommentsListCommand() {
2013
3081
  return command;
2014
3082
  }
2015
3083
  function createCommentsAddCommand() {
2016
- const command = new Command12("add");
2017
- 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").action(async (idStr, text, options) => {
3084
+ const command = new Command13("add");
3085
+ 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) => {
2018
3086
  validateOrgProjectPair(options);
2019
3087
  const id = parseWorkItemId(idStr);
2020
3088
  if (text.trim() === "") {
@@ -2023,8 +3091,9 @@ function createCommentsAddCommand() {
2023
3091
  let context;
2024
3092
  try {
2025
3093
  context = resolveContext(options);
2026
- const credential = await resolvePat();
2027
- const result = await addWorkItemComment(context, id, credential.pat, text);
3094
+ const credential = await requireAuthCredential(context.org);
3095
+ const format = options.markdown === true ? "markdown" : "html";
3096
+ const result = await addWorkItemComment(context, id, credential, text, format);
2028
3097
  if (options.json) {
2029
3098
  process.stdout.write(`${JSON.stringify(result, null, 2)}
2030
3099
  `);
@@ -2039,17 +3108,65 @@ function createCommentsAddCommand() {
2039
3108
  return command;
2040
3109
  }
2041
3110
  function createCommentsCommand() {
2042
- const command = new Command12("comments");
3111
+ const command = new Command13("comments");
2043
3112
  command.description("Manage Azure DevOps work item comments");
2044
3113
  command.addCommand(createCommentsListCommand());
2045
3114
  command.addCommand(createCommentsAddCommand());
2046
3115
  return command;
2047
3116
  }
2048
3117
 
3118
+ // src/commands/download-attachment.ts
3119
+ import { Command as Command14 } from "commander";
3120
+ import { writeFile } from "fs/promises";
3121
+ import { existsSync as existsSync4 } from "fs";
3122
+ import { join as join2 } from "path";
3123
+ function createDownloadAttachmentCommand() {
3124
+ const command = new Command14("download-attachment");
3125
+ 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(
3126
+ async (idStr, filename, options) => {
3127
+ const id = parseWorkItemId(idStr);
3128
+ validateOrgProjectPair(options);
3129
+ let context;
3130
+ try {
3131
+ context = resolveContext(options);
3132
+ const credential = await requireAuthCredential(context.org);
3133
+ const outputDir = options.output ?? ".";
3134
+ if (!existsSync4(outputDir)) {
3135
+ process.stderr.write(`Error: Output directory "${outputDir}" does not exist.
3136
+ `);
3137
+ process.exit(1);
3138
+ }
3139
+ const workItem = await getWorkItem(context, id, credential);
3140
+ const attachment = workItem.attachments?.find(
3141
+ (a) => a.name === filename
3142
+ );
3143
+ if (!attachment) {
3144
+ process.stderr.write(
3145
+ `Error: Attachment "${filename}" not found on work item ${id}.
3146
+ `
3147
+ );
3148
+ process.exit(1);
3149
+ }
3150
+ const data = await downloadAttachment(attachment.url, credential);
3151
+ const outputPath = join2(outputDir, filename);
3152
+ await writeFile(outputPath, Buffer.from(data));
3153
+ process.stdout.write(
3154
+ `Downloaded "${filename}" (${formatFileSize(attachment.size)}) to ${outputPath}
3155
+ `
3156
+ );
3157
+ } catch (err) {
3158
+ handleCommandError(err, id, context, "read", false);
3159
+ }
3160
+ }
3161
+ );
3162
+ return command;
3163
+ }
3164
+
2049
3165
  // src/index.ts
2050
- var program = new Command13();
3166
+ var program = new Command15();
2051
3167
  program.name("azdo").description("Azure DevOps CLI tool").version(version, "-v, --version");
2052
3168
  program.addCommand(createGetItemCommand());
3169
+ program.addCommand(createAuthCommand());
2053
3170
  program.addCommand(createClearPatCommand());
2054
3171
  program.addCommand(createConfigCommand());
2055
3172
  program.addCommand(createSetStateCommand());
@@ -2061,6 +3178,7 @@ program.addCommand(createUpsertCommand());
2061
3178
  program.addCommand(createListFieldsCommand());
2062
3179
  program.addCommand(createPrCommand());
2063
3180
  program.addCommand(createCommentsCommand());
3181
+ program.addCommand(createDownloadAttachmentCommand());
2064
3182
  program.showHelpAfterError();
2065
3183
  program.parse();
2066
3184
  if (process.argv.length <= 2) {