azdo-cli 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,4 +1,34 @@
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
34
  import { Command as Command15 } from "commander";
@@ -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) {
@@ -188,10 +229,10 @@ function buildWorkItemUrl(context, id, options = {}) {
188
229
  }
189
230
  return url;
190
231
  }
191
- async function fetchWorkItemResponse(context, id, pat, options = {}) {
232
+ async function fetchWorkItemResponse(context, id, cred, options = {}) {
192
233
  const response = await fetchWithErrors(
193
234
  buildWorkItemUrl(context, id, options).toString(),
194
- { headers: authHeaders(pat) }
235
+ { headers: authHeaders(cred) }
195
236
  );
196
237
  if (response.status === 400) {
197
238
  const serverMessage = await readResponseMessage(response);
@@ -204,12 +245,12 @@ async function fetchWorkItemResponse(context, id, pat, options = {}) {
204
245
  }
205
246
  return await response.json();
206
247
  }
207
- async function getWorkItem(context, id, pat, extraFields) {
248
+ async function getWorkItem(context, id, cred, extraFields) {
208
249
  const normalizedExtraFields = extraFields ? normalizeFieldList(extraFields) : [];
209
- const data = normalizedExtraFields.length > 0 ? await fetchWorkItemResponse(context, id, pat, {
250
+ const data = normalizedExtraFields.length > 0 ? await fetchWorkItemResponse(context, id, cred, {
210
251
  fields: normalizeFieldList([...DEFAULT_FIELDS, ...normalizedExtraFields])
211
- }) : await fetchWorkItemResponse(context, id, pat, { includeRelations: true });
212
- const relationsData = normalizedExtraFields.length > 0 ? await fetchWorkItemResponse(context, id, pat, { includeRelations: true }) : data;
252
+ }) : await fetchWorkItemResponse(context, id, cred, { includeRelations: true });
253
+ const relationsData = normalizedExtraFields.length > 0 ? await fetchWorkItemResponse(context, id, cred, { includeRelations: true }) : data;
213
254
  const descriptionParts = [];
214
255
  if (data.fields["System.Description"]) {
215
256
  descriptionParts.push({ label: "Description", value: data.fields["System.Description"] });
@@ -241,13 +282,13 @@ async function getWorkItem(context, id, pat, extraFields) {
241
282
  attachments: extractAttachments(relationsData.relations)
242
283
  };
243
284
  }
244
- async function getWorkItemFieldValue(context, id, pat, fieldName) {
285
+ async function getWorkItemFieldValue(context, id, cred, fieldName) {
245
286
  const url = new URL(
246
287
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
247
288
  );
248
289
  url.searchParams.set("api-version", "7.1");
249
290
  url.searchParams.set("fields", fieldName);
250
- const response = await fetchWithErrors(url.toString(), { headers: authHeaders(pat) });
291
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
251
292
  if (response.status === 400) {
252
293
  const serverMessage = await readResponseMessage(response);
253
294
  if (serverMessage) {
@@ -264,13 +305,13 @@ async function getWorkItemFieldValue(context, id, pat, fieldName) {
264
305
  }
265
306
  return stringifyFieldValue(value);
266
307
  }
267
- async function listWorkItemComments(context, id, pat) {
308
+ async function listWorkItemComments(context, id, cred) {
268
309
  const comments = [];
269
310
  let continuationToken = null;
270
311
  do {
271
312
  const response = await fetchWithErrors(
272
313
  buildWorkItemCommentsListUrl(context, id, continuationToken ?? void 0).toString(),
273
- { headers: authHeaders(pat) }
314
+ { headers: authHeaders(cred) }
274
315
  );
275
316
  if (!response.ok) {
276
317
  throw new Error(`HTTP_${response.status}`);
@@ -287,13 +328,13 @@ async function listWorkItemComments(context, id, pat) {
287
328
  comments
288
329
  };
289
330
  }
290
- async function addWorkItemComment(context, id, pat, text, format = "html") {
331
+ async function addWorkItemComment(context, id, cred, text, format = "html") {
291
332
  const url = buildWorkItemCommentsUrl(context, id);
292
333
  url.searchParams.set("format", format);
293
334
  const response = await fetchWithErrors(url.toString(), {
294
335
  method: "POST",
295
336
  headers: {
296
- ...authHeaders(pat),
337
+ ...authHeaders(cred),
297
338
  "Content-Type": "application/json"
298
339
  },
299
340
  body: JSON.stringify({ text })
@@ -315,8 +356,8 @@ async function addWorkItemComment(context, id, pat, text, format = "html") {
315
356
  url: data.url ?? null
316
357
  };
317
358
  }
318
- async function updateWorkItem(context, id, pat, fieldName, operations) {
319
- 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);
320
361
  const title = result.fields["System.Title"];
321
362
  const lastOp = operations.at(-1);
322
363
  const fieldValue = lastOp?.value ?? null;
@@ -328,32 +369,32 @@ async function updateWorkItem(context, id, pat, fieldName, operations) {
328
369
  fieldValue
329
370
  };
330
371
  }
331
- async function createWorkItem(context, workItemType, pat, operations) {
372
+ async function createWorkItem(context, workItemType, cred, operations) {
332
373
  const url = new URL(
333
374
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/$${encodeURIComponent(workItemType)}`
334
375
  );
335
376
  url.searchParams.set("api-version", "7.1");
336
377
  const response = await fetchWithErrors(url.toString(), {
337
378
  method: "POST",
338
- headers: writeHeaders(pat),
379
+ headers: writeHeaders(cred),
339
380
  body: JSON.stringify(operations)
340
381
  });
341
382
  return readWriteResponse(response, "CREATE_REJECTED");
342
383
  }
343
- async function applyWorkItemPatch(context, id, pat, operations) {
384
+ async function applyWorkItemPatch(context, id, cred, operations) {
344
385
  const url = new URL(
345
386
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
346
387
  );
347
388
  url.searchParams.set("api-version", "7.1");
348
389
  const response = await fetchWithErrors(url.toString(), {
349
390
  method: "PATCH",
350
- headers: writeHeaders(pat),
391
+ headers: writeHeaders(cred),
351
392
  body: JSON.stringify(operations)
352
393
  });
353
394
  return readWriteResponse(response, "UPDATE_REJECTED");
354
395
  }
355
- async function downloadAttachment(url, pat) {
356
- const response = await fetchWithErrors(url, { headers: authHeaders(pat) });
396
+ async function downloadAttachment(url, cred) {
397
+ const response = await fetchWithErrors(url, { headers: authHeaders(cred) });
357
398
  if (!response.ok) {
358
399
  throw new Error(`HTTP_${response.status}`);
359
400
  }
@@ -365,323 +406,124 @@ import { createInterface } from "readline";
365
406
  import { existsSync, readFileSync as readFileSync2 } from "fs";
366
407
  import { dirname as dirname2, join } from "path";
367
408
 
368
- // src/services/credential-store.ts
369
- import { Entry } from "@napi-rs/keyring";
370
-
371
- // src/types/credential.ts
372
- var CredentialStoreUnavailableError = class extends Error {
373
- backend;
374
- constructor(backend, cause) {
375
- super(`OS secret backend unavailable (${backend}). Install the platform's credential service and try again.`);
376
- this.name = "CredentialStoreUnavailableError";
377
- this.backend = backend;
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;
378
416
  if (cause instanceof Error) {
379
417
  this.cause = cause;
380
418
  }
381
419
  }
382
420
  };
383
-
384
- // src/services/audit-log.ts
385
- import fs from "fs";
386
- import os from "os";
387
- import path from "path";
388
- function getAuditLogPath() {
389
- return path.join(os.homedir(), ".azdo", "audit.log");
390
- }
391
- function ensureDirWithPerms(dir) {
392
- if (!fs.existsSync(dir)) {
393
- fs.mkdirSync(dir, { recursive: true, mode: 448 });
394
- return;
395
- }
396
- try {
397
- fs.chmodSync(dir, 448);
398
- } catch {
399
- }
400
- }
401
- function ensureFileWithPerms(file) {
402
- if (!fs.existsSync(file)) {
403
- fs.writeFileSync(file, "", { mode: 384 });
404
- return;
405
- }
421
+ var MIN_INTERVAL_SEC = 5;
422
+ async function defaultSleep(ms) {
423
+ await new Promise((r) => setTimeout(r, ms));
424
+ }
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;
406
437
  try {
407
- fs.chmodSync(file, 384);
438
+ parsed = JSON.parse(text);
408
439
  } catch {
440
+ throw new DeviceCodeFlowError("idp-error", `device-code endpoint returned non-JSON HTTP ${response.status}: ${text.slice(0, 200)}`);
409
441
  }
410
- }
411
- function appendAuthAuditEvent(input) {
412
- const auditLog = getAuditLogPath();
413
- const dir = path.dirname(auditLog);
414
- ensureDirWithPerms(dir);
415
- ensureFileWithPerms(auditLog);
416
- const record = {
417
- ts: (/* @__PURE__ */ new Date()).toISOString(),
418
- event: input.event,
419
- org: input.org,
420
- backend: input.backend,
421
- ...input.masked_pat !== void 0 ? { masked_pat: input.masked_pat } : {}
422
- };
423
- fs.appendFileSync(auditLog, `${JSON.stringify(record)}
424
- `);
425
- }
426
- function readAuditEvents() {
427
- const auditLog = getAuditLogPath();
428
- if (!fs.existsSync(auditLog)) {
429
- return [];
430
- }
431
- const contents = fs.readFileSync(auditLog, "utf8");
432
- const out = [];
433
- for (const line of contents.split("\n")) {
434
- const trimmed = line.trim();
435
- if (!trimmed) continue;
436
- try {
437
- const parsed = JSON.parse(trimmed);
438
- if (parsed && typeof parsed === "object" && typeof parsed.event === "string") {
439
- out.push(parsed);
440
- }
441
- } catch {
442
- }
443
- }
444
- return out;
445
- }
446
-
447
- // src/services/config-store.ts
448
- import fs2 from "fs";
449
- import path2 from "path";
450
- import os2 from "os";
451
- var SETTINGS = [
452
- {
453
- key: "org",
454
- description: "Azure DevOps organization name",
455
- type: "string",
456
- example: "mycompany",
457
- required: true
458
- },
459
- {
460
- key: "project",
461
- description: "Azure DevOps project name",
462
- type: "string",
463
- example: "MyProject",
464
- required: true
465
- },
466
- {
467
- key: "fields",
468
- description: "Extra work item fields to include (comma-separated reference names)",
469
- type: "string[]",
470
- example: "System.Tags,Custom.Priority",
471
- required: false
472
- },
473
- {
474
- key: "markdown",
475
- description: "Convert rich text fields to markdown on display",
476
- type: "boolean",
477
- example: "true",
478
- required: false
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
+ );
479
450
  }
480
- ];
481
- var VALID_KEYS = SETTINGS.map((s) => s.key);
482
- function getConfigPath() {
483
- return path2.join(os2.homedir(), ".azdo", "config.json");
451
+ return parsed;
484
452
  }
485
- function loadConfig() {
486
- const configPath = getConfigPath();
487
- let raw;
488
- try {
489
- raw = fs2.readFileSync(configPath, "utf-8");
490
- } catch (err) {
491
- if (err.code === "ENOENT") {
492
- return {};
493
- }
494
- throw err;
453
+ async function classifyDeviceTokenResponse(response) {
454
+ if (response.ok) {
455
+ return { kind: "success", token: await readTokenResponse(response) };
495
456
  }
457
+ const text = await response.text();
458
+ let parsed;
496
459
  try {
497
- return JSON.parse(raw);
460
+ parsed = JSON.parse(text);
498
461
  } catch {
499
- process.stderr.write(`Warning: Config file ${configPath} contains invalid JSON. Using defaults.
500
- `);
501
- return {};
462
+ throw new DeviceCodeFlowError("idp-error", `non-JSON HTTP ${response.status}: ${text.slice(0, 200)}`);
502
463
  }
503
- }
504
- function saveConfig(config) {
505
- const configPath = getConfigPath();
506
- const dir = path2.dirname(configPath);
507
- fs2.mkdirSync(dir, { recursive: true });
508
- fs2.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
509
- }
510
- function validateKey(key) {
511
- if (!VALID_KEYS.includes(key)) {
512
- throw new Error(`Unknown setting key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
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");
513
469
  }
514
- }
515
- function getConfigValue(key) {
516
- validateKey(key);
517
- const config = loadConfig();
518
- return config[key];
519
- }
520
- function setConfigValue(key, value) {
521
- validateKey(key);
522
- const config = loadConfig();
523
- if (value === "") {
524
- delete config[key];
525
- } else if (key === "markdown") {
526
- if (value !== "true" && value !== "false") {
527
- throw new Error(`Invalid value "${value}" for markdown. Must be "true" or "false".`);
528
- }
529
- config.markdown = value === "true";
530
- } else if (key === "fields") {
531
- config.fields = value.split(",").map((s) => s.trim());
532
- } else {
533
- config[key] = value;
534
- }
535
- saveConfig(config);
536
- }
537
- function unsetConfigValue(key) {
538
- validateKey(key);
539
- const config = loadConfig();
540
- delete config[key];
541
- saveConfig(config);
542
- }
543
-
544
- // src/services/auth-masking.ts
545
- var VISIBLE_CHARS = 5;
546
- function maskedDisplay(pat) {
547
- if (pat.length <= VISIBLE_CHARS * 2) {
548
- return pat;
549
- }
550
- const hiddenCount = pat.length - VISIBLE_CHARS * 2;
551
- return pat.slice(0, VISIBLE_CHARS) + "*".repeat(hiddenCount) + pat.slice(-VISIBLE_CHARS);
552
- }
553
- function normalizePat(rawPat) {
554
- const trimmedPat = rawPat.trim();
555
- return trimmedPat.length > 0 ? trimmedPat : null;
556
- }
557
-
558
- // src/services/credential-store.ts
559
- var SERVICE = "azdo-cli";
560
- var LEGACY_ACCOUNT = "pat";
561
- function accountFor(org) {
562
- return `pat:${org}`;
563
- }
564
- function probeBackend() {
565
- switch (process.platform) {
566
- case "win32":
567
- return "windows-credential-manager";
568
- case "darwin":
569
- return "macos-keychain";
570
- case "linux":
571
- return "linux-libsecret";
572
- default:
573
- return "unknown";
574
- }
575
- }
576
- function wrapUnavailable(fn) {
577
- try {
578
- return fn();
579
- } catch (err) {
580
- throw new CredentialStoreUnavailableError(probeBackend(), err);
470
+ if (errCode === "access_denied") {
471
+ throw new DeviceCodeFlowError("access_denied", "authorisation denied by user");
581
472
  }
582
- }
583
- var legacyUnsetNoticeEmitted = false;
584
- function emitLegacyUnsetNoticeOnce() {
585
- if (legacyUnsetNoticeEmitted) return;
586
- legacyUnsetNoticeEmitted = true;
587
- process.stderr.write(
588
- 'A legacy PAT exists in the OS vault from a previous azdo-cli version, but no "org" is set in config. Run `azdo auth --org <name>` to re-store it under the per-org key, then `azdo clear-pat` to remove the legacy slot.\n'
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}`
589
477
  );
590
478
  }
591
- async function maybeMigrateLegacy(targetOrg) {
592
- const config = loadConfig();
593
- if (!config.org || config.org !== targetOrg) {
594
- if (!config.org) {
595
- let legacyExists;
596
- try {
597
- const legacyEntry2 = new Entry(SERVICE, LEGACY_ACCOUNT);
598
- legacyExists = legacyEntry2.getPassword() !== null;
599
- } catch {
600
- legacyExists = false;
601
- }
602
- if (legacyExists) {
603
- emitLegacyUnsetNoticeOnce();
604
- }
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;
605
503
  }
606
- return null;
607
- }
608
- const newEntry = new Entry(SERVICE, accountFor(targetOrg));
609
- const existingNew = wrapUnavailable(() => newEntry.getPassword());
610
- if (existingNew !== null) {
611
- return null;
612
- }
613
- const legacyEntry = new Entry(SERVICE, LEGACY_ACCOUNT);
614
- const legacy = wrapUnavailable(() => legacyEntry.getPassword());
615
- if (legacy === null) {
616
- return null;
617
- }
618
- wrapUnavailable(() => {
619
- newEntry.setPassword(legacy);
620
- legacyEntry.deletePassword();
621
- });
622
- appendAuthAuditEvent({
623
- event: "auth.store",
624
- org: targetOrg,
625
- backend: probeBackend(),
626
- masked_pat: maskedDisplay(legacy)
627
- });
628
- process.stderr.write(`Migrated legacy PAT to org ${targetOrg}.
629
- `);
630
- return legacy;
631
- }
632
- async function getPat(org) {
633
- const entry = new Entry(SERVICE, accountFor(org));
634
- const value = wrapUnavailable(() => entry.getPassword());
635
- if (value !== null) {
636
- return value;
637
504
  }
638
- const migrated = await maybeMigrateLegacy(org);
639
- return migrated;
640
505
  }
641
- async function storePat(org, pat) {
642
- const entry = new Entry(SERVICE, accountFor(org));
643
- wrapUnavailable(() => entry.setPassword(pat));
644
- appendAuthAuditEvent({
645
- event: "auth.store",
646
- org,
647
- backend: probeBackend(),
648
- masked_pat: maskedDisplay(pat)
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);
649
511
  });
650
- }
651
- async function deletePat(org) {
652
- const entry = new Entry(SERVICE, accountFor(org));
653
- const existing = wrapUnavailable(() => entry.getPassword());
654
- if (existing === null) {
655
- return false;
656
- }
657
- wrapUnavailable(() => entry.deletePassword());
658
- appendAuthAuditEvent({
659
- event: "auth.delete",
660
- org,
661
- backend: probeBackend(),
662
- masked_pat: maskedDisplay(existing)
663
- });
664
- return true;
665
- }
666
- async function listOrgsWithStoredPat() {
667
- const seen = /* @__PURE__ */ new Set();
668
- for (const ev of readAuditEvents()) {
669
- if (ev.event === "auth.store") {
670
- seen.add(ev.org);
671
- } else if (ev.event === "auth.delete") {
672
- seen.delete(ev.org);
673
- }
674
- }
675
- const present = [];
676
- for (const org of seen) {
677
- const entry = new Entry(SERVICE, accountFor(org));
678
- const value = wrapUnavailable(() => entry.getPassword());
679
- if (value !== null) {
680
- present.push(org);
681
- }
682
- }
683
- present.sort((a, b) => a.localeCompare(b));
684
- return present;
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:
517
+
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" };
685
527
  }
686
528
 
687
529
  // src/services/auth.ts
@@ -750,29 +592,36 @@ function findDotEnvPat(startDir = process.cwd()) {
750
592
  }
751
593
  return null;
752
594
  }
753
- async function resolvePat(org) {
595
+ async function resolveAuthCredential(org) {
754
596
  const envPat = process.env.AZDO_PAT;
755
597
  if (envPat && envPat.length > 0) {
756
- return { pat: envPat, source: "env" };
598
+ return { pat: envPat, source: "env", kind: "pat" };
757
599
  }
758
- const storedPat = await getPat(org);
759
- if (storedPat !== null) {
760
- 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
+ };
761
612
  }
762
613
  const dotEnvPat = findDotEnvPat();
763
614
  if (dotEnvPat !== null) {
764
- return { pat: dotEnvPat, source: "env" };
615
+ return { pat: dotEnvPat, source: "env", kind: "pat" };
765
616
  }
766
617
  return null;
767
618
  }
768
- async function requirePat(org) {
769
- const cred = await resolvePat(org);
619
+ async function requireAuthCredential(org) {
620
+ const cred = await resolveAuthCredential(org);
770
621
  if (cred !== null) {
771
622
  return cred;
772
623
  }
773
- throw new Error(
774
- `No PAT available for org "${org}". Set AZDO_PAT environment variable or run \`azdo auth --org ${org}\`.`
775
- );
624
+ throw new CredentialMissingError(org);
776
625
  }
777
626
  async function validatePatAgainstAzdo(pat, org) {
778
627
  const url = `https://dev.azure.com/${encodeURIComponent(org)}/_apis/projects?$top=1&api-version=7.1`;
@@ -791,25 +640,168 @@ async function validatePatAgainstAzdo(pat, org) {
791
640
  }
792
641
  throw new Error(`Azure DevOps returned HTTP ${response.status} while validating PAT for org "${org}".`);
793
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;
658
+ }
659
+ }
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 };
765
+ }
794
766
 
795
767
  // src/services/git-remote.ts
796
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
797
785
  var patterns = [
798
- // HTTPS (current): https://dev.azure.com/{org}/{project}/_git/{repo}
799
- /^https?:\/\/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+)$/,
800
- // HTTPS (legacy + DefaultCollection): https://{org}.visualstudio.com/DefaultCollection/{project}/_git/{repo}
801
- /^https?:\/\/([^.]+)\.visualstudio\.com\/DefaultCollection\/([^/]+)\/_git\/([^/]+)$/,
802
- // HTTPS (legacy): https://{org}.visualstudio.com/{project}/_git/{repo}
803
- /^https?:\/\/([^.]+)\.visualstudio\.com\/([^/]+)\/_git\/([^/]+)$/,
804
- // SSH (current): git@ssh.dev.azure.com:v3/{org}/{project}/{repo}
805
- /^git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/([^/]+)$/,
806
- // SSH (legacy): {org}@vs-ssh.visualstudio.com:v3/{org}/{project}/{repo}
807
- /^[^@]+@vs-ssh\.visualstudio\.com:v3\/([^/]+)\/([^/]+)\/([^/]+)$/
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)?$/
808
796
  ];
797
+ var httpsUserinfo = /^https?:\/\/[^@/]+@/;
809
798
  function parseAzdoRemote(url) {
810
799
  for (const pattern of patterns) {
811
800
  const match = pattern.exec(url);
812
801
  if (match) {
802
+ if (httpsUserinfo.test(url)) {
803
+ noticeCredentialBearingRemote();
804
+ }
813
805
  const project = match[2];
814
806
  if (/^DefaultCollection$/i.test(project)) {
815
807
  return { org: match[1], project: "" };
@@ -836,6 +828,9 @@ function parseRepoName(url) {
836
828
  for (const pattern of patterns) {
837
829
  const match = pattern.exec(url);
838
830
  if (match) {
831
+ if (httpsUserinfo.test(url)) {
832
+ noticeCredentialBearingRemote();
833
+ }
839
834
  return match[3];
840
835
  }
841
836
  }
@@ -1073,12 +1068,12 @@ function stripHtml(html) {
1073
1068
  text = text.replaceAll(/<\/?(p|div)>/gi, "\n");
1074
1069
  text = text.replaceAll(/<li>/gi, "\n");
1075
1070
  text = removeHtmlTags(text);
1076
- text = text.replaceAll("&amp;", "&");
1077
1071
  text = text.replaceAll("&lt;", "<");
1078
1072
  text = text.replaceAll("&gt;", ">");
1079
1073
  text = text.replaceAll("&quot;", '"');
1080
1074
  text = text.replaceAll("&#39;", "'");
1081
1075
  text = text.replaceAll("&nbsp;", " ");
1076
+ text = text.replaceAll("&amp;", "&");
1082
1077
  text = text.replaceAll(/\n{3,}/g, "\n\n");
1083
1078
  return text.trim();
1084
1079
  }
@@ -1188,9 +1183,9 @@ function createGetItemCommand() {
1188
1183
  let context;
1189
1184
  try {
1190
1185
  context = resolveContext(options);
1191
- const credential = await requirePat(context.org);
1186
+ const credential = await requireAuthCredential(context.org);
1192
1187
  const fieldsList = options.fields === void 0 ? parseRequestedFields(loadConfig().fields) : parseRequestedFields(options.fields);
1193
- const workItem = await getWorkItem(context, id, credential.pat, fieldsList);
1188
+ const workItem = await getWorkItem(context, id, credential, fieldsList);
1194
1189
  const markdownEnabled = options.markdown ?? loadConfig().markdown ?? false;
1195
1190
  const output = formatWorkItem(workItem, options.short ?? false, markdownEnabled);
1196
1191
  process.stdout.write(output + "\n");
@@ -1229,63 +1224,6 @@ function createClearPatCommand() {
1229
1224
 
1230
1225
  // src/commands/auth.ts
1231
1226
  import { Command as Command3 } from "commander";
1232
-
1233
- // src/services/browser-open.ts
1234
- import { execFile } from "child_process";
1235
- function isHeadless(platform, hasDisplay) {
1236
- if (platform === "linux") {
1237
- return !hasDisplay;
1238
- }
1239
- return false;
1240
- }
1241
- function commandForPlatform(platform) {
1242
- switch (platform) {
1243
- case "darwin":
1244
- return { cmd: "open", args: (url) => [url] };
1245
- case "win32":
1246
- return { cmd: "cmd", args: (url) => ["/c", "start", '""', url] };
1247
- case "linux":
1248
- return { cmd: "xdg-open", args: (url) => [url] };
1249
- default:
1250
- return null;
1251
- }
1252
- }
1253
- async function openUrl(url, opts = {}) {
1254
- const platform = opts.platform ?? process.platform;
1255
- const hasDisplay = opts.hasDisplay ?? (process.env.DISPLAY !== void 0 && process.env.DISPLAY !== "");
1256
- const forcePrint = opts.forcePrint ?? false;
1257
- if (forcePrint || isHeadless(platform, hasDisplay)) {
1258
- process.stderr.write(`Open this URL in your browser: ${url}
1259
- `);
1260
- return "printed";
1261
- }
1262
- const spec = commandForPlatform(platform);
1263
- if (!spec) {
1264
- process.stderr.write(`Open this URL in your browser: ${url}
1265
- `);
1266
- return "printed";
1267
- }
1268
- const runner = opts.execFileFn ?? ((cmd, args, cb) => execFile(cmd, args, { timeout: 5e3 }, (err) => cb(err)));
1269
- return await new Promise((resolve2) => {
1270
- try {
1271
- runner(spec.cmd, spec.args(url), (err) => {
1272
- if (err) {
1273
- process.stderr.write(`Open this URL in your browser: ${url}
1274
- `);
1275
- resolve2("printed");
1276
- } else {
1277
- resolve2("opened");
1278
- }
1279
- });
1280
- } catch {
1281
- process.stderr.write(`Open this URL in your browser: ${url}
1282
- `);
1283
- resolve2("printed");
1284
- }
1285
- });
1286
- }
1287
-
1288
- // src/commands/auth.ts
1289
1227
  async function readStdinToString() {
1290
1228
  const chunks = [];
1291
1229
  for await (const chunk of process.stdin) {
@@ -1293,9 +1231,9 @@ async function readStdinToString() {
1293
1231
  }
1294
1232
  return Buffer.concat(chunks).toString("utf8");
1295
1233
  }
1296
- async function confirmOverwrite(org) {
1234
+ async function promptYesNo(prompt) {
1297
1235
  if (!process.stdin.isTTY) return true;
1298
- process.stderr.write(`A PAT is already stored for org ${org}. Overwrite? [y/N] `);
1236
+ process.stderr.write(prompt);
1299
1237
  return await new Promise((resolve2) => {
1300
1238
  process.stdin.setEncoding("utf8");
1301
1239
  let answered = false;
@@ -1311,7 +1249,23 @@ async function confirmOverwrite(org) {
1311
1249
  process.stdin.on("data", handler);
1312
1250
  });
1313
1251
  }
1314
- async function handleAuthRoot(options) {
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) {
1315
1269
  const resolved = resolveOrg({ org: options.org });
1316
1270
  if (!resolved) {
1317
1271
  process.stderr.write(`${formatResolutionError()}
@@ -1377,7 +1331,145 @@ async function handleAuthRoot(options) {
1377
1331
  process.stdout.write(`PAT stored for org ${org} in ${probeBackend()}.
1378
1332
  `);
1379
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
+ }
1380
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
+ }
1381
1473
  let backend;
1382
1474
  let value;
1383
1475
  try {
@@ -1393,39 +1485,25 @@ async function handleStatus(options, org) {
1393
1485
  throw err;
1394
1486
  }
1395
1487
  const storedEvents = readAuditEvents().filter((ev) => ev.org === org && ev.event === "auth.store");
1396
- const last = storedEvents[storedEvents.length - 1];
1488
+ const last = storedEvents.at(-1);
1397
1489
  const updatedAt = last?.ts ?? null;
1398
1490
  if (!value) {
1399
- if (options.json) {
1400
- process.stdout.write(
1401
- `${JSON.stringify({ org, backend, stored: false, masked: null, updated_at: updatedAt })}
1402
- `
1403
- );
1404
- } else {
1405
- process.stdout.write(`Organization: ${org}
1491
+ process.stdout.write(`Organization: ${org}
1406
1492
  Backend: ${backend}
1407
1493
  Stored: no
1408
1494
  `);
1409
- }
1410
1495
  process.exitCode = 1;
1411
1496
  return;
1412
1497
  }
1413
1498
  const masked = maskedDisplay(value);
1414
- if (options.json) {
1415
- process.stdout.write(
1416
- `${JSON.stringify({ org, backend, stored: true, masked, updated_at: updatedAt })}
1417
- `
1418
- );
1419
- } else {
1420
- process.stdout.write(
1421
- `Organization: ${org}
1499
+ process.stdout.write(
1500
+ `Organization: ${org}
1422
1501
  Backend: ${backend}
1423
1502
  Stored: yes
1424
1503
  Identifier: ${masked}
1425
1504
  ` + (updatedAt ? `Last updated: ${updatedAt}
1426
1505
  ` : "")
1427
- );
1428
- }
1506
+ );
1429
1507
  }
1430
1508
  async function handleLogout(options, orgFromGlobal) {
1431
1509
  if (options.all && orgFromGlobal) {
@@ -1434,9 +1512,16 @@ async function handleLogout(options, orgFromGlobal) {
1434
1512
  return;
1435
1513
  }
1436
1514
  if (options.all) {
1437
- let orgs;
1438
1515
  try {
1439
- orgs = await listOrgsWithStoredPat();
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
+ }
1440
1525
  } catch (err) {
1441
1526
  if (err instanceof CredentialStoreUnavailableError) {
1442
1527
  process.stderr.write(`${err.message}
@@ -1444,22 +1529,9 @@ async function handleLogout(options, orgFromGlobal) {
1444
1529
  process.exitCode = 4;
1445
1530
  return;
1446
1531
  }
1447
- throw err;
1448
- }
1449
- if (orgs.length === 0) {
1450
- process.stdout.write("No stored PATs to remove.\n");
1451
- return;
1452
- }
1453
- for (const org of orgs) {
1454
- try {
1455
- await deletePat(org);
1456
- process.stdout.write(`PAT removed for org ${org}.
1457
- `);
1458
- } catch (err) {
1459
- process.stderr.write(`Failed to remove PAT for org ${org}: ${err.message}
1532
+ process.stderr.write(`Failed to remove credentials: ${err.message}
1460
1533
  `);
1461
- process.exitCode = 1;
1462
- }
1534
+ process.exitCode = 1;
1463
1535
  }
1464
1536
  return;
1465
1537
  }
@@ -1473,10 +1545,10 @@ async function handleLogout(options, orgFromGlobal) {
1473
1545
  try {
1474
1546
  const removed = await deletePat(resolved.org);
1475
1547
  if (removed) {
1476
- process.stdout.write(`PAT removed for org ${resolved.org}.
1548
+ process.stdout.write(`Credential removed for org ${resolved.org}.
1477
1549
  `);
1478
1550
  } else {
1479
- process.stdout.write(`No stored PAT found for org ${resolved.org}.
1551
+ process.stdout.write(`No stored credential for org ${resolved.org}.
1480
1552
  `);
1481
1553
  }
1482
1554
  } catch (err) {
@@ -1491,14 +1563,70 @@ async function handleLogout(options, orgFromGlobal) {
1491
1563
  }
1492
1564
  function createAuthCommand() {
1493
1565
  const command = new Command3("auth");
1494
- command.description("Manage Azure DevOps Personal Access Tokens (PAT) in the OS secret vault");
1495
- command.option("--org <name>", "Azure DevOps organization (flag wins over auto-detect / config)").option("--from-stdin", "read PAT from stdin instead of prompting", false).option("--no-browser", "do not open the Azure DevOps PAT page in a browser");
1496
- command.action(async (options) => {
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) => {
1497
1601
  await handleAuthRoot(options);
1498
1602
  });
1499
- const statusCmd = command.command("status").description("Report whether a PAT is stored for the resolved org (masked, never the full value)").option("--json", "emit a JSON object", false);
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);
1500
1609
  statusCmd.action(async (options) => {
1501
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
+ }
1502
1630
  const resolved = resolveOrg({ org: globals.org });
1503
1631
  if (!resolved) {
1504
1632
  process.stderr.write(`${formatResolutionError()}
@@ -1508,7 +1636,11 @@ function createAuthCommand() {
1508
1636
  }
1509
1637
  await handleStatus(options, resolved.org);
1510
1638
  });
1511
- const logoutCmd = command.command("logout").description("Remove the stored PAT for an org (or all orgs with --all)").option("--all", "remove the stored PAT for every org", false);
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);
1512
1644
  logoutCmd.action(async (options) => {
1513
1645
  const globals = logoutCmd.optsWithGlobals();
1514
1646
  await handleLogout(options, globals.org);
@@ -1690,11 +1822,11 @@ function createSetStateCommand() {
1690
1822
  let context;
1691
1823
  try {
1692
1824
  context = resolveContext(options);
1693
- const credential = await requirePat(context.org);
1825
+ const credential = await requireAuthCredential(context.org);
1694
1826
  const operations = [
1695
1827
  { op: "add", path: "/fields/System.State", value: state }
1696
1828
  ];
1697
- const result = await updateWorkItem(context, id, credential.pat, "System.State", operations);
1829
+ const result = await updateWorkItem(context, id, credential, "System.State", operations);
1698
1830
  if (options.json) {
1699
1831
  process.stdout.write(
1700
1832
  JSON.stringify({
@@ -1740,12 +1872,12 @@ function createAssignCommand() {
1740
1872
  let context;
1741
1873
  try {
1742
1874
  context = resolveContext(options);
1743
- const credential = await requirePat(context.org);
1875
+ const credential = await requireAuthCredential(context.org);
1744
1876
  const value = options.unassign ? "" : name;
1745
1877
  const operations = [
1746
1878
  { op: "add", path: "/fields/System.AssignedTo", value }
1747
1879
  ];
1748
- const result = await updateWorkItem(context, id, credential.pat, "System.AssignedTo", operations);
1880
+ const result = await updateWorkItem(context, id, credential, "System.AssignedTo", operations);
1749
1881
  if (options.json) {
1750
1882
  process.stdout.write(
1751
1883
  JSON.stringify({
@@ -1780,11 +1912,11 @@ function createSetFieldCommand() {
1780
1912
  let context;
1781
1913
  try {
1782
1914
  context = resolveContext(options);
1783
- const credential = await requirePat(context.org);
1915
+ const credential = await requireAuthCredential(context.org);
1784
1916
  const operations = [
1785
1917
  { op: "add", path: `/fields/${field}`, value }
1786
1918
  ];
1787
- const result = await updateWorkItem(context, id, credential.pat, field, operations);
1919
+ const result = await updateWorkItem(context, id, credential, field, operations);
1788
1920
  if (options.json) {
1789
1921
  process.stdout.write(
1790
1922
  JSON.stringify({
@@ -1818,8 +1950,8 @@ function createGetMdFieldCommand() {
1818
1950
  let context;
1819
1951
  try {
1820
1952
  context = resolveContext(options);
1821
- const credential = await requirePat(context.org);
1822
- 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);
1823
1955
  if (value === null) {
1824
1956
  process.stdout.write("\n");
1825
1957
  } else {
@@ -1906,12 +2038,12 @@ function createSetMdFieldCommand() {
1906
2038
  let context;
1907
2039
  try {
1908
2040
  context = resolveContext(options);
1909
- const credential = await requirePat(context.org);
2041
+ const credential = await requireAuthCredential(context.org);
1910
2042
  const operations = [
1911
2043
  { op: "add", path: `/fields/${field}`, value: content },
1912
2044
  { op: "add", path: `/multilineFieldsFormat/${field}`, value: "Markdown" }
1913
2045
  ];
1914
- const result = await updateWorkItem(context, id, credential.pat, field, operations);
2046
+ const result = await updateWorkItem(context, id, credential, field, operations);
1915
2047
  formatOutput(result, options, field);
1916
2048
  } catch (err) {
1917
2049
  handleCommandError(err, id, context, "write");
@@ -2222,15 +2354,15 @@ function createUpsertCommand() {
2222
2354
  ensureTitleForCreate(document.fields);
2223
2355
  }
2224
2356
  const operations = toPatchOperations(document.fields, action);
2225
- const credential = await requirePat(context.org);
2357
+ const credential = await requireAuthCredential(context.org);
2226
2358
  let writeResult;
2227
2359
  if (action === "created") {
2228
- writeResult = await createWorkItem(context, createType, credential.pat, operations);
2360
+ writeResult = await createWorkItem(context, createType, credential, operations);
2229
2361
  } else {
2230
2362
  if (id === void 0) {
2231
2363
  fail2("Work item ID is required for updates.");
2232
2364
  }
2233
- writeResult = await applyWorkItemPatch(context, id, credential.pat, operations);
2365
+ writeResult = await applyWorkItemPatch(context, id, credential, operations);
2234
2366
  }
2235
2367
  const result = buildUpsertResult(
2236
2368
  action,
@@ -2288,8 +2420,8 @@ function createListFieldsCommand() {
2288
2420
  let context;
2289
2421
  try {
2290
2422
  context = resolveContext(options);
2291
- const credential = await requirePat(context.org);
2292
- const fields = await getWorkItemFields(context, id, credential.pat);
2423
+ const credential = await requireAuthCredential(context.org);
2424
+ const fields = await getWorkItemFields(context, id, credential);
2293
2425
  if (options.json) {
2294
2426
  process.stdout.write(JSON.stringify({ id, fields }, null, 2) + "\n");
2295
2427
  } else {
@@ -2343,9 +2475,9 @@ function mapPullRequest(repo, pullRequest) {
2343
2475
  url: pullRequest._links?.web?.href ?? null
2344
2476
  };
2345
2477
  }
2346
- function mapPullRequestCheckName(status) {
2347
- const genre = status.context?.genre?.trim();
2348
- const name = status.context?.name?.trim();
2478
+ function mapPullRequestCheckName(status2) {
2479
+ const genre = status2.context?.genre?.trim();
2480
+ const name = status2.context?.name?.trim();
2349
2481
  if (genre && name) {
2350
2482
  return `${genre}/${name}`;
2351
2483
  }
@@ -2355,21 +2487,21 @@ function mapPullRequestCheckName(status) {
2355
2487
  if (genre) {
2356
2488
  return genre;
2357
2489
  }
2358
- return `Status #${status.id}`;
2490
+ return `Status #${status2.id}`;
2359
2491
  }
2360
- function mapPullRequestCheck(status) {
2361
- if (status.state === "notApplicable" || status.state === "notSet") {
2492
+ function mapPullRequestCheck(status2) {
2493
+ if (status2.state === "notApplicable" || status2.state === "notSet") {
2362
2494
  return null;
2363
2495
  }
2364
2496
  return {
2365
- id: status.id,
2366
- state: status.state,
2367
- name: mapPullRequestCheckName(status),
2368
- description: status.description ?? null,
2369
- targetUrl: status.targetUrl ?? null,
2370
- createdBy: status.createdBy?.displayName ?? null,
2371
- createdAt: status.creationDate ?? null,
2372
- updatedAt: status.updatedDate ?? null
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
2373
2505
  };
2374
2506
  }
2375
2507
  function mapComment(comment) {
@@ -2405,8 +2537,8 @@ function toActiveCommentThread(thread) {
2405
2537
  };
2406
2538
  }
2407
2539
  var RESOLVED_THREAD_STATUSES = /* @__PURE__ */ new Set(["fixed", "wontFix", "closed", "byDesign"]);
2408
- function isThreadResolved(status) {
2409
- return RESOLVED_THREAD_STATUSES.has(status);
2540
+ function isThreadResolved(status2) {
2541
+ return RESOLVED_THREAD_STATUSES.has(status2);
2410
2542
  }
2411
2543
  async function readJsonResponse(response) {
2412
2544
  if (!response.ok) {
@@ -2414,7 +2546,7 @@ async function readJsonResponse(response) {
2414
2546
  }
2415
2547
  return response.json();
2416
2548
  }
2417
- async function patchThreadStatus(context, repo, pat, prId, threadId, status) {
2549
+ async function patchThreadStatus(context, repo, cred, prId, threadId, status2) {
2418
2550
  const url = new URL(
2419
2551
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}/threads/${threadId}`
2420
2552
  );
@@ -2422,41 +2554,41 @@ async function patchThreadStatus(context, repo, pat, prId, threadId, status) {
2422
2554
  const response = await fetchWithErrors(url.toString(), {
2423
2555
  method: "PATCH",
2424
2556
  headers: {
2425
- ...authHeaders(pat),
2557
+ ...authHeaders(cred),
2426
2558
  "Content-Type": "application/json"
2427
2559
  },
2428
- body: JSON.stringify({ status })
2560
+ body: JSON.stringify({ status: status2 })
2429
2561
  });
2430
2562
  const data = await readJsonResponse(response);
2431
2563
  return toActiveCommentThread(data);
2432
2564
  }
2433
- async function getPullRequestById(context, repo, pat, prId) {
2565
+ async function getPullRequestById(context, repo, cred, prId) {
2434
2566
  const url = new URL(
2435
2567
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}`
2436
2568
  );
2437
2569
  url.searchParams.set("api-version", "7.1");
2438
- const response = await fetchWithErrors(url.toString(), { headers: authHeaders(pat) });
2570
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
2439
2571
  const data = await readJsonResponse(response);
2440
2572
  return mapPullRequest(repo, data);
2441
2573
  }
2442
- async function listPullRequests(context, repo, pat, sourceBranch, opts) {
2574
+ async function listPullRequests(context, repo, cred, sourceBranch, opts) {
2443
2575
  const response = await fetchWithErrors(
2444
2576
  buildPullRequestsUrl(context, repo, sourceBranch, opts).toString(),
2445
- { headers: authHeaders(pat) }
2577
+ { headers: authHeaders(cred) }
2446
2578
  );
2447
2579
  const data = await readJsonResponse(response);
2448
2580
  return data.value.map((pullRequest) => mapPullRequest(repo, pullRequest));
2449
2581
  }
2450
- async function getPullRequestChecks(context, repo, pat, prId) {
2582
+ async function getPullRequestChecks(context, repo, cred, prId) {
2451
2583
  const response = await fetchWithErrors(
2452
2584
  buildPullRequestStatusesUrl(context, repo, prId).toString(),
2453
- { headers: authHeaders(pat) }
2585
+ { headers: authHeaders(cred) }
2454
2586
  );
2455
2587
  const data = await readJsonResponse(response);
2456
2588
  return data.value.map(mapPullRequestCheck).filter((check) => check !== null);
2457
2589
  }
2458
- async function openPullRequest(context, repo, pat, sourceBranch, title, description) {
2459
- const existing = await listPullRequests(context, repo, pat, sourceBranch, {
2590
+ async function openPullRequest(context, repo, cred, sourceBranch, title, description) {
2591
+ const existing = await listPullRequests(context, repo, cred, sourceBranch, {
2460
2592
  status: "active",
2461
2593
  targetBranch: "develop"
2462
2594
  });
@@ -2484,7 +2616,7 @@ async function openPullRequest(context, repo, pat, sourceBranch, title, descript
2484
2616
  const response = await fetchWithErrors(url.toString(), {
2485
2617
  method: "POST",
2486
2618
  headers: {
2487
- ...authHeaders(pat),
2619
+ ...authHeaders(cred),
2488
2620
  "Content-Type": "application/json"
2489
2621
  },
2490
2622
  body: JSON.stringify(payload)
@@ -2497,12 +2629,12 @@ async function openPullRequest(context, repo, pat, sourceBranch, title, descript
2497
2629
  pullRequest: mapPullRequest(repo, data)
2498
2630
  };
2499
2631
  }
2500
- async function getPullRequestThreads(context, repo, pat, prId) {
2632
+ async function getPullRequestThreads(context, repo, cred, prId) {
2501
2633
  const url = new URL(
2502
2634
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}/threads`
2503
2635
  );
2504
2636
  url.searchParams.set("api-version", "7.1");
2505
- const response = await fetchWithErrors(url.toString(), { headers: authHeaders(pat) });
2637
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
2506
2638
  const data = await readJsonResponse(response);
2507
2639
  return data.value.map(mapThread).filter((thread) => thread !== null);
2508
2640
  }
@@ -2515,6 +2647,21 @@ function parsePositivePrNumber(raw) {
2515
2647
  const n = Number.parseInt(raw, 10);
2516
2648
  return Number.isFinite(n) && n > 0 ? n : null;
2517
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
+ }
2518
2665
  function formatBranchName(refName) {
2519
2666
  return refName.startsWith("refs/heads/") ? refName.slice("refs/heads/".length) : refName;
2520
2667
  }
@@ -2569,8 +2716,8 @@ function formatPullRequestBlock(pullRequest) {
2569
2716
  ...formatPullRequestChecks(pullRequest.checks)
2570
2717
  ].join("\n");
2571
2718
  }
2572
- function threadStatusLabel(status) {
2573
- return isThreadResolved(status) ? "resolved" : status;
2719
+ function threadStatusLabel(status2) {
2720
+ return isThreadResolved(status2) ? "resolved" : status2;
2574
2721
  }
2575
2722
  function formatThreads(prId, title, threads) {
2576
2723
  const lines = [`Comment threads for pull request #${prId}: ${title}`];
@@ -2587,12 +2734,12 @@ async function resolvePrCommandContext(options, resolveOpts = {}) {
2587
2734
  const context = resolveContext(options);
2588
2735
  const repo = detectRepoName();
2589
2736
  const branch = requireBranch ? getCurrentBranch() : null;
2590
- const credential = await requirePat(context.org);
2737
+ const credential = await requireAuthCredential(context.org);
2591
2738
  return {
2592
2739
  context,
2593
2740
  repo,
2594
2741
  branch,
2595
- pat: credential.pat
2742
+ pat: credential
2596
2743
  };
2597
2744
  }
2598
2745
  function createPrStatusCommand() {
@@ -2690,7 +2837,7 @@ ${result.pullRequest.url ?? "\u2014"}
2690
2837
  }
2691
2838
  function createPrCommentsCommand() {
2692
2839
  const command = new Command12("comments");
2693
- command.description("List pull request comment threads for the current branch").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--pr-number <N>", "target the pull request with this numeric id, instead of the current branch's PR").option("--hide-resolved", "hide threads whose status is resolved / won't fix / closed / by design").option("--json", "output JSON").action(async (options) => {
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) => {
2694
2841
  validateOrgProjectPair(options);
2695
2842
  let context;
2696
2843
  let explicitPrId = null;
@@ -2722,12 +2869,11 @@ function createPrCommentsCommand() {
2722
2869
  status: "active"
2723
2870
  });
2724
2871
  if (pullRequests.length === 0) {
2725
- writeError(`No active pull request found for branch ${resolved.branch}.`);
2872
+ writeContractError(autoDetectZeroMatch(resolved.branch));
2726
2873
  return;
2727
2874
  }
2728
2875
  if (pullRequests.length > 1) {
2729
- const ids = pullRequests.map((pr) => `#${pr.id}`).join(", ");
2730
- writeError(`Multiple active pull requests found for branch ${resolved.branch}: ${ids}. Use pr status to review them.`);
2876
+ writeContractError(autoDetectMultiMatch(resolved.branch, pullRequests.map((pr) => pr.id)));
2731
2877
  return;
2732
2878
  }
2733
2879
  pullRequest = pullRequests[0];
@@ -2791,12 +2937,11 @@ async function resolveThreadTarget(threadIdRaw, options) {
2791
2937
  status: "active"
2792
2938
  });
2793
2939
  if (pullRequests.length === 0) {
2794
- writeError(`No active pull request found for branch ${resolved.branch}.`);
2940
+ writeContractError(autoDetectZeroMatch(resolved.branch));
2795
2941
  return null;
2796
2942
  }
2797
2943
  if (pullRequests.length > 1) {
2798
- const ids = pullRequests.map((pr) => `#${pr.id}`).join(", ");
2799
- writeError(`Multiple active pull requests found for branch ${resolved.branch}: ${ids}. Use pr status to review them.`);
2944
+ writeContractError(autoDetectMultiMatch(resolved.branch, pullRequests.map((pr) => pr.id)));
2800
2945
  return null;
2801
2946
  }
2802
2947
  pullRequest = pullRequests[0];
@@ -2864,14 +3009,14 @@ async function runThreadStateChange(threadIdRaw, options, direction) {
2864
3009
  }
2865
3010
  function createPrCommentResolveCommand() {
2866
3011
  const command = new Command12("comment-resolve");
2867
- command.description("Mark a pull request comment thread as resolved").argument("<threadId>", "numeric id of the thread to resolve").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--pr-number <N>", "target the pull request with this numeric id, instead of the current branch's PR").option("--json", "output JSON").action(async (threadIdRaw, options) => {
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) => {
2868
3013
  await runThreadStateChange(threadIdRaw, options, "resolve");
2869
3014
  });
2870
3015
  return command;
2871
3016
  }
2872
3017
  function createPrCommentReopenCommand() {
2873
3018
  const command = new Command12("comment-reopen");
2874
- command.description("Reopen (set to active) a previously resolved pull request comment thread").argument("<threadId>", "numeric id of the thread to reopen").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--pr-number <N>", "target the pull request with this numeric id, instead of the current branch's PR").option("--json", "output JSON").action(async (threadIdRaw, options) => {
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) => {
2875
3020
  await runThreadStateChange(threadIdRaw, options, "reopen");
2876
3021
  });
2877
3022
  return command;
@@ -2915,8 +3060,8 @@ function createCommentsListCommand() {
2915
3060
  let context;
2916
3061
  try {
2917
3062
  context = resolveContext(options);
2918
- const credential = await requirePat(context.org);
2919
- const result = await listWorkItemComments(context, id, credential.pat);
3063
+ const credential = await requireAuthCredential(context.org);
3064
+ const result = await listWorkItemComments(context, id, credential);
2920
3065
  if (options.json) {
2921
3066
  process.stdout.write(`${JSON.stringify(result, null, 2)}
2922
3067
  `);
@@ -2946,9 +3091,9 @@ function createCommentsAddCommand() {
2946
3091
  let context;
2947
3092
  try {
2948
3093
  context = resolveContext(options);
2949
- const credential = await requirePat(context.org);
3094
+ const credential = await requireAuthCredential(context.org);
2950
3095
  const format = options.markdown === true ? "markdown" : "html";
2951
- const result = await addWorkItemComment(context, id, credential.pat, text, format);
3096
+ const result = await addWorkItemComment(context, id, credential, text, format);
2952
3097
  if (options.json) {
2953
3098
  process.stdout.write(`${JSON.stringify(result, null, 2)}
2954
3099
  `);
@@ -2984,14 +3129,14 @@ function createDownloadAttachmentCommand() {
2984
3129
  let context;
2985
3130
  try {
2986
3131
  context = resolveContext(options);
2987
- const credential = await requirePat(context.org);
3132
+ const credential = await requireAuthCredential(context.org);
2988
3133
  const outputDir = options.output ?? ".";
2989
3134
  if (!existsSync4(outputDir)) {
2990
3135
  process.stderr.write(`Error: Output directory "${outputDir}" does not exist.
2991
3136
  `);
2992
3137
  process.exit(1);
2993
3138
  }
2994
- const workItem = await getWorkItem(context, id, credential.pat);
3139
+ const workItem = await getWorkItem(context, id, credential);
2995
3140
  const attachment = workItem.attachments?.find(
2996
3141
  (a) => a.name === filename
2997
3142
  );
@@ -3002,7 +3147,7 @@ function createDownloadAttachmentCommand() {
3002
3147
  );
3003
3148
  process.exit(1);
3004
3149
  }
3005
- const data = await downloadAttachment(attachment.url, credential.pat);
3150
+ const data = await downloadAttachment(attachment.url, credential);
3006
3151
  const outputPath = join2(outputDir, filename);
3007
3152
  await writeFile(outputPath, Buffer.from(data));
3008
3153
  process.stdout.write(