azdo-cli 0.10.0-develop.268 → 0.10.0-develop.317

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;
470
+ if (errCode === "access_denied") {
471
+ throw new DeviceCodeFlowError("access_denied", "authorisation denied by user");
549
472
  }
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);
581
- }
582
- }
583
- var legacyUnsetNoticeEmitted = false;
584
- function emitLegacyUnsetNoticeOnce() {
585
- if (legacyUnsetNoticeEmitted) return;
586
- legacyUnsetNoticeEmitted = true;
587
- process.stderr.write(
588
- 'A legacy PAT exists in the OS vault from a previous azdo-cli version, but no "org" is set in config. Run `azdo auth --org <name>` to re-store it under the per-org key, then `azdo clear-pat` to remove the legacy slot.\n'
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
504
  }
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
505
  }
632
- async function getPat(org) {
633
- const entry = new Entry(SERVICE, accountFor(org));
634
- const value = wrapUnavailable(() => entry.getPassword());
635
- if (value !== null) {
636
- return value;
637
- }
638
- const migrated = await maybeMigrateLegacy(org);
639
- return migrated;
640
- }
641
- async function storePat(org, pat) {
642
- const entry = new Entry(SERVICE, accountFor(org));
643
- wrapUnavailable(() => entry.setPassword(pat));
644
- appendAuthAuditEvent({
645
- event: "auth.store",
646
- org,
647
- backend: probeBackend(),
648
- masked_pat: maskedDisplay(pat)
649
- });
650
- }
651
- async function deletePat(org) {
652
- const entry = new Entry(SERVICE, accountFor(org));
653
- const existing = wrapUnavailable(() => entry.getPassword());
654
- if (existing === null) {
655
- return false;
656
- }
657
- wrapUnavailable(() => entry.deletePassword());
658
- appendAuthAuditEvent({
659
- event: "auth.delete",
660
- org,
661
- backend: probeBackend(),
662
- masked_pat: maskedDisplay(existing)
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);
663
511
  });
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,6 +640,129 @@ 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";
@@ -1073,12 +1045,12 @@ function stripHtml(html) {
1073
1045
  text = text.replaceAll(/<\/?(p|div)>/gi, "\n");
1074
1046
  text = text.replaceAll(/<li>/gi, "\n");
1075
1047
  text = removeHtmlTags(text);
1076
- text = text.replaceAll("&amp;", "&");
1077
1048
  text = text.replaceAll("&lt;", "<");
1078
1049
  text = text.replaceAll("&gt;", ">");
1079
1050
  text = text.replaceAll("&quot;", '"');
1080
1051
  text = text.replaceAll("&#39;", "'");
1081
1052
  text = text.replaceAll("&nbsp;", " ");
1053
+ text = text.replaceAll("&amp;", "&");
1082
1054
  text = text.replaceAll(/\n{3,}/g, "\n\n");
1083
1055
  return text.trim();
1084
1056
  }
@@ -1188,9 +1160,9 @@ function createGetItemCommand() {
1188
1160
  let context;
1189
1161
  try {
1190
1162
  context = resolveContext(options);
1191
- const credential = await requirePat(context.org);
1163
+ const credential = await requireAuthCredential(context.org);
1192
1164
  const fieldsList = options.fields === void 0 ? parseRequestedFields(loadConfig().fields) : parseRequestedFields(options.fields);
1193
- const workItem = await getWorkItem(context, id, credential.pat, fieldsList);
1165
+ const workItem = await getWorkItem(context, id, credential, fieldsList);
1194
1166
  const markdownEnabled = options.markdown ?? loadConfig().markdown ?? false;
1195
1167
  const output = formatWorkItem(workItem, options.short ?? false, markdownEnabled);
1196
1168
  process.stdout.write(output + "\n");
@@ -1229,63 +1201,6 @@ function createClearPatCommand() {
1229
1201
 
1230
1202
  // src/commands/auth.ts
1231
1203
  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
1204
  async function readStdinToString() {
1290
1205
  const chunks = [];
1291
1206
  for await (const chunk of process.stdin) {
@@ -1293,9 +1208,9 @@ async function readStdinToString() {
1293
1208
  }
1294
1209
  return Buffer.concat(chunks).toString("utf8");
1295
1210
  }
1296
- async function confirmOverwrite(org) {
1211
+ async function promptYesNo(prompt) {
1297
1212
  if (!process.stdin.isTTY) return true;
1298
- process.stderr.write(`A PAT is already stored for org ${org}. Overwrite? [y/N] `);
1213
+ process.stderr.write(prompt);
1299
1214
  return await new Promise((resolve2) => {
1300
1215
  process.stdin.setEncoding("utf8");
1301
1216
  let answered = false;
@@ -1311,7 +1226,23 @@ async function confirmOverwrite(org) {
1311
1226
  process.stdin.on("data", handler);
1312
1227
  });
1313
1228
  }
1314
- async function handleAuthRoot(options) {
1229
+ async function confirmOverwrite(org) {
1230
+ return promptYesNo(`A PAT is already stored for org ${org}. Overwrite? [y/N] `);
1231
+ }
1232
+ async function confirmOverwriteCredential(org, existingKind) {
1233
+ const label = existingKind === "oauth" ? "OAuth credential" : "PAT";
1234
+ return promptYesNo(`A ${label} is already stored for org ${org}. The new login will replace it. Continue? [y/N] `);
1235
+ }
1236
+ function rejectMutuallyExclusive(opts) {
1237
+ if (opts.usePat && opts.deviceCode) {
1238
+ return "--use-pat and --device-code are mutually exclusive (PAT has no device-code flow).";
1239
+ }
1240
+ if (opts.usePat && (opts.clientId || opts.tenantId || opts.scopes)) {
1241
+ return "--use-pat cannot be combined with OAuth-only flags (--client-id / --tenant-id / --scopes).";
1242
+ }
1243
+ return null;
1244
+ }
1245
+ async function handlePatLogin(options) {
1315
1246
  const resolved = resolveOrg({ org: options.org });
1316
1247
  if (!resolved) {
1317
1248
  process.stderr.write(`${formatResolutionError()}
@@ -1377,7 +1308,145 @@ async function handleAuthRoot(options) {
1377
1308
  process.stdout.write(`PAT stored for org ${org} in ${probeBackend()}.
1378
1309
  `);
1379
1310
  }
1311
+ async function ensureOverwriteConfirmed(org) {
1312
+ try {
1313
+ const existing = await getStoredCredential(org);
1314
+ if (existing === null || !process.stdin.isTTY) return "ok";
1315
+ const ok = await confirmOverwriteCredential(org, existing.kind);
1316
+ return ok ? "ok" : "aborted";
1317
+ } catch (err) {
1318
+ if (err instanceof CredentialStoreUnavailableError) {
1319
+ process.stderr.write(`${err.message}
1320
+ `);
1321
+ return "unavailable";
1322
+ }
1323
+ throw err;
1324
+ }
1325
+ }
1326
+ function buildOAuthLoginOptions(options) {
1327
+ return {
1328
+ flow: options.deviceCode ? "device-code" : "auto",
1329
+ clientIdOverride: options.clientId,
1330
+ tenantIdOverride: options.tenantId,
1331
+ scopesOverride: options.scopes ? options.scopes.split(/\s+/).filter(Boolean) : void 0
1332
+ };
1333
+ }
1334
+ function reportOAuthFailure(err) {
1335
+ const reason = typeof err === "object" && err !== null && "reason" in err ? err.reason : null;
1336
+ const msg = err.message;
1337
+ process.stderr.write(reason ? `OAuth login failed (${reason}): ${msg}
1338
+ ` : `OAuth login failed: ${msg}
1339
+ `);
1340
+ const noDisplay = process.platform === "linux" && (!process.env.DISPLAY || process.env.DISPLAY.length === 0);
1341
+ if (noDisplay) {
1342
+ process.stderr.write("Tip: this host has no DISPLAY; pass --device-code to use the headless flow.\n");
1343
+ } else if (reason === "port-conflict") {
1344
+ process.stderr.write("Tip: another process is using the loopback callback port. Try again or pass --device-code.\n");
1345
+ }
1346
+ }
1347
+ async function handleOAuthLogin(options) {
1348
+ const resolved = resolveOrg({ org: options.org });
1349
+ if (!resolved) {
1350
+ process.stderr.write(`${formatResolutionError()}
1351
+ `);
1352
+ process.exitCode = 3;
1353
+ return;
1354
+ }
1355
+ const org = resolved.org;
1356
+ const confirm = await ensureOverwriteConfirmed(org);
1357
+ if (confirm === "aborted") {
1358
+ process.stderr.write("Aborted. Existing credential preserved.\n");
1359
+ process.exitCode = 1;
1360
+ return;
1361
+ }
1362
+ if (confirm === "unavailable") {
1363
+ process.exitCode = 4;
1364
+ return;
1365
+ }
1366
+ try {
1367
+ const result = await loginWithOAuth(org, buildOAuthLoginOptions(options));
1368
+ process.stdout.write(
1369
+ `Logged in to ${org} via OAuth (${result.flowUsed}). Account: ${result.accountId}; expires ${new Date(result.expiresAt * 1e3).toISOString()}.
1370
+ `
1371
+ );
1372
+ } catch (err) {
1373
+ reportOAuthFailure(err);
1374
+ process.exitCode = 1;
1375
+ }
1376
+ }
1377
+ async function handleAuthRoot(options) {
1378
+ const conflict = rejectMutuallyExclusive(options);
1379
+ if (conflict) {
1380
+ process.stderr.write(`${conflict}
1381
+ `);
1382
+ process.exitCode = 2;
1383
+ return;
1384
+ }
1385
+ await handlePatLogin(options);
1386
+ }
1387
+ async function handleLoginSubcommand(options) {
1388
+ const conflict = rejectMutuallyExclusive(options);
1389
+ if (conflict) {
1390
+ process.stderr.write(`${conflict}
1391
+ `);
1392
+ process.exitCode = 2;
1393
+ return;
1394
+ }
1395
+ if (options.fromStdin || options.usePat) {
1396
+ await handlePatLogin(options);
1397
+ return;
1398
+ }
1399
+ await handleOAuthLogin(options);
1400
+ }
1401
+ async function handleStatusJson() {
1402
+ try {
1403
+ const report = await status();
1404
+ process.stdout.write(`${JSON.stringify(report)}
1405
+ `);
1406
+ } catch (err) {
1407
+ if (err instanceof CredentialStoreUnavailableError) {
1408
+ process.stderr.write(`${err.message}
1409
+ `);
1410
+ process.exitCode = 4;
1411
+ return;
1412
+ }
1413
+ throw err;
1414
+ }
1415
+ }
1380
1416
  async function handleStatus(options, org) {
1417
+ if (options.json) {
1418
+ let backend2;
1419
+ let value2;
1420
+ try {
1421
+ backend2 = probeBackend();
1422
+ value2 = await getPat(org);
1423
+ } catch (err) {
1424
+ if (err instanceof CredentialStoreUnavailableError) {
1425
+ process.stderr.write(`${err.message}
1426
+ `);
1427
+ process.exitCode = 4;
1428
+ return;
1429
+ }
1430
+ throw err;
1431
+ }
1432
+ const storedEvents2 = readAuditEvents().filter((ev) => ev.org === org && ev.event === "auth.store");
1433
+ const last2 = storedEvents2.at(-1);
1434
+ const updatedAt2 = last2?.ts ?? null;
1435
+ if (!value2) {
1436
+ process.stdout.write(
1437
+ `${JSON.stringify({ org, backend: backend2, stored: false, masked: null, updated_at: updatedAt2 })}
1438
+ `
1439
+ );
1440
+ process.exitCode = 1;
1441
+ return;
1442
+ }
1443
+ const masked2 = maskedDisplay(value2);
1444
+ process.stdout.write(
1445
+ `${JSON.stringify({ org, backend: backend2, stored: true, masked: masked2, updated_at: updatedAt2 })}
1446
+ `
1447
+ );
1448
+ return;
1449
+ }
1381
1450
  let backend;
1382
1451
  let value;
1383
1452
  try {
@@ -1393,39 +1462,25 @@ async function handleStatus(options, org) {
1393
1462
  throw err;
1394
1463
  }
1395
1464
  const storedEvents = readAuditEvents().filter((ev) => ev.org === org && ev.event === "auth.store");
1396
- const last = storedEvents[storedEvents.length - 1];
1465
+ const last = storedEvents.at(-1);
1397
1466
  const updatedAt = last?.ts ?? null;
1398
1467
  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}
1468
+ process.stdout.write(`Organization: ${org}
1406
1469
  Backend: ${backend}
1407
1470
  Stored: no
1408
1471
  `);
1409
- }
1410
1472
  process.exitCode = 1;
1411
1473
  return;
1412
1474
  }
1413
1475
  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}
1476
+ process.stdout.write(
1477
+ `Organization: ${org}
1422
1478
  Backend: ${backend}
1423
1479
  Stored: yes
1424
1480
  Identifier: ${masked}
1425
1481
  ` + (updatedAt ? `Last updated: ${updatedAt}
1426
1482
  ` : "")
1427
- );
1428
- }
1483
+ );
1429
1484
  }
1430
1485
  async function handleLogout(options, orgFromGlobal) {
1431
1486
  if (options.all && orgFromGlobal) {
@@ -1434,9 +1489,16 @@ async function handleLogout(options, orgFromGlobal) {
1434
1489
  return;
1435
1490
  }
1436
1491
  if (options.all) {
1437
- let orgs;
1438
1492
  try {
1439
- orgs = await listOrgsWithStoredPat();
1493
+ const result = await logout({ all: true });
1494
+ if (result.removed.length === 0) {
1495
+ process.stdout.write("No stored credentials to remove.\n");
1496
+ return;
1497
+ }
1498
+ for (const r of result.removed) {
1499
+ process.stdout.write(`Removed ${r.kind} credential for org ${r.org}.
1500
+ `);
1501
+ }
1440
1502
  } catch (err) {
1441
1503
  if (err instanceof CredentialStoreUnavailableError) {
1442
1504
  process.stderr.write(`${err.message}
@@ -1444,22 +1506,9 @@ async function handleLogout(options, orgFromGlobal) {
1444
1506
  process.exitCode = 4;
1445
1507
  return;
1446
1508
  }
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}.
1509
+ process.stderr.write(`Failed to remove credentials: ${err.message}
1457
1510
  `);
1458
- } catch (err) {
1459
- process.stderr.write(`Failed to remove PAT for org ${org}: ${err.message}
1460
- `);
1461
- process.exitCode = 1;
1462
- }
1511
+ process.exitCode = 1;
1463
1512
  }
1464
1513
  return;
1465
1514
  }
@@ -1473,10 +1522,10 @@ async function handleLogout(options, orgFromGlobal) {
1473
1522
  try {
1474
1523
  const removed = await deletePat(resolved.org);
1475
1524
  if (removed) {
1476
- process.stdout.write(`PAT removed for org ${resolved.org}.
1525
+ process.stdout.write(`Credential removed for org ${resolved.org}.
1477
1526
  `);
1478
1527
  } else {
1479
- process.stdout.write(`No stored PAT found for org ${resolved.org}.
1528
+ process.stdout.write(`No stored credential for org ${resolved.org}.
1480
1529
  `);
1481
1530
  }
1482
1531
  } catch (err) {
@@ -1491,14 +1540,70 @@ async function handleLogout(options, orgFromGlobal) {
1491
1540
  }
1492
1541
  function createAuthCommand() {
1493
1542
  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) => {
1543
+ command.description(
1544
+ "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."
1545
+ );
1546
+ 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)");
1547
+ command.addHelpText(
1548
+ "after",
1549
+ `
1550
+ Default flow (OAuth, browser-based):
1551
+ azdo auth login --org <name>
1552
+ \u2192 opens the default browser for OAuth (Microsoft Entra v2 + PKCE).
1553
+
1554
+ Headless / no-browser:
1555
+ azdo auth login --org <name> --device-code
1556
+
1557
+ PAT path (legacy, opt-in):
1558
+ azdo auth login --org <name> --use-pat
1559
+ azdo auth --org <name> # back-compat alias of the above
1560
+
1561
+ OAuth scope set \u2014 shipped first-party client (default install):
1562
+ ${firstPartyShippedScopes().join("\n ")}
1563
+ (uses ${AZDO_RESOURCE_ID}/.default \u2014 per-scope consent is unavailable
1564
+ against a client we do not own; .default grants the VS client's
1565
+ pre-authorized AzDO permissions in one step.)
1566
+
1567
+ OAuth scope set \u2014 self-registered apps (--client-id / AZDO_OAUTH_CLIENT_ID):
1568
+ ${defaultScopes().join("\n ")}
1569
+ (FR-016, mirrors the PAT scope table \u2014 see docs/oauth-app-registration.md)
1570
+
1571
+ For self-registered OAuth apps (locked-down tenants), see docs/oauth-app-registration.md
1572
+ \u2014 that same guide is the maintainer reference for the project's shared client id.
1573
+
1574
+ Note: stored credentials may coexist as 'pat' or 'oauth' across orgs (FR-007).
1575
+ Note: \`azdo auth\` (no subcommand) preserves the legacy PAT-prompt entry point;
1576
+ \`azdo auth login\` is the spec-canonical name and defaults to OAuth.`
1577
+ ).action(async (options) => {
1497
1578
  await handleAuthRoot(options);
1498
1579
  });
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);
1580
+ 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)");
1581
+ loginCmd.action(async () => {
1582
+ const merged = loginCmd.optsWithGlobals();
1583
+ await handleLoginSubcommand(merged);
1584
+ });
1585
+ const statusCmd = command.command("status").description("Report stored credentials (kind, org, account/expiry, backend) \u2014 never the token").option("--json", "emit JSON", false);
1500
1586
  statusCmd.action(async (options) => {
1501
1587
  const globals = statusCmd.optsWithGlobals();
1588
+ if (!globals.org) {
1589
+ if (options.json) {
1590
+ await handleStatusJson();
1591
+ return;
1592
+ }
1593
+ const report = await status();
1594
+ if (report.orgs.length === 0) {
1595
+ process.stdout.write("No stored credentials.\n");
1596
+ return;
1597
+ }
1598
+ for (const e of report.orgs) {
1599
+ const expiry = e.expiresAt ? new Date(e.expiresAt * 1e3).toISOString() : "n/a";
1600
+ process.stdout.write(
1601
+ `${e.org} ${e.kind} ${e.accountId ?? ""} ${expiry}
1602
+ `
1603
+ );
1604
+ }
1605
+ return;
1606
+ }
1502
1607
  const resolved = resolveOrg({ org: globals.org });
1503
1608
  if (!resolved) {
1504
1609
  process.stderr.write(`${formatResolutionError()}
@@ -1508,7 +1613,11 @@ function createAuthCommand() {
1508
1613
  }
1509
1614
  await handleStatus(options, resolved.org);
1510
1615
  });
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);
1616
+ statusCmd.addHelpText(
1617
+ "after",
1618
+ "\nStored credentials may be of kind `pat` or `oauth` and may coexist across orgs (FR-007).\n"
1619
+ );
1620
+ 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
1621
  logoutCmd.action(async (options) => {
1513
1622
  const globals = logoutCmd.optsWithGlobals();
1514
1623
  await handleLogout(options, globals.org);
@@ -1690,11 +1799,11 @@ function createSetStateCommand() {
1690
1799
  let context;
1691
1800
  try {
1692
1801
  context = resolveContext(options);
1693
- const credential = await requirePat(context.org);
1802
+ const credential = await requireAuthCredential(context.org);
1694
1803
  const operations = [
1695
1804
  { op: "add", path: "/fields/System.State", value: state }
1696
1805
  ];
1697
- const result = await updateWorkItem(context, id, credential.pat, "System.State", operations);
1806
+ const result = await updateWorkItem(context, id, credential, "System.State", operations);
1698
1807
  if (options.json) {
1699
1808
  process.stdout.write(
1700
1809
  JSON.stringify({
@@ -1740,12 +1849,12 @@ function createAssignCommand() {
1740
1849
  let context;
1741
1850
  try {
1742
1851
  context = resolveContext(options);
1743
- const credential = await requirePat(context.org);
1852
+ const credential = await requireAuthCredential(context.org);
1744
1853
  const value = options.unassign ? "" : name;
1745
1854
  const operations = [
1746
1855
  { op: "add", path: "/fields/System.AssignedTo", value }
1747
1856
  ];
1748
- const result = await updateWorkItem(context, id, credential.pat, "System.AssignedTo", operations);
1857
+ const result = await updateWorkItem(context, id, credential, "System.AssignedTo", operations);
1749
1858
  if (options.json) {
1750
1859
  process.stdout.write(
1751
1860
  JSON.stringify({
@@ -1780,11 +1889,11 @@ function createSetFieldCommand() {
1780
1889
  let context;
1781
1890
  try {
1782
1891
  context = resolveContext(options);
1783
- const credential = await requirePat(context.org);
1892
+ const credential = await requireAuthCredential(context.org);
1784
1893
  const operations = [
1785
1894
  { op: "add", path: `/fields/${field}`, value }
1786
1895
  ];
1787
- const result = await updateWorkItem(context, id, credential.pat, field, operations);
1896
+ const result = await updateWorkItem(context, id, credential, field, operations);
1788
1897
  if (options.json) {
1789
1898
  process.stdout.write(
1790
1899
  JSON.stringify({
@@ -1818,8 +1927,8 @@ function createGetMdFieldCommand() {
1818
1927
  let context;
1819
1928
  try {
1820
1929
  context = resolveContext(options);
1821
- const credential = await requirePat(context.org);
1822
- const value = await getWorkItemFieldValue(context, id, credential.pat, field);
1930
+ const credential = await requireAuthCredential(context.org);
1931
+ const value = await getWorkItemFieldValue(context, id, credential, field);
1823
1932
  if (value === null) {
1824
1933
  process.stdout.write("\n");
1825
1934
  } else {
@@ -1906,12 +2015,12 @@ function createSetMdFieldCommand() {
1906
2015
  let context;
1907
2016
  try {
1908
2017
  context = resolveContext(options);
1909
- const credential = await requirePat(context.org);
2018
+ const credential = await requireAuthCredential(context.org);
1910
2019
  const operations = [
1911
2020
  { op: "add", path: `/fields/${field}`, value: content },
1912
2021
  { op: "add", path: `/multilineFieldsFormat/${field}`, value: "Markdown" }
1913
2022
  ];
1914
- const result = await updateWorkItem(context, id, credential.pat, field, operations);
2023
+ const result = await updateWorkItem(context, id, credential, field, operations);
1915
2024
  formatOutput(result, options, field);
1916
2025
  } catch (err) {
1917
2026
  handleCommandError(err, id, context, "write");
@@ -2222,15 +2331,15 @@ function createUpsertCommand() {
2222
2331
  ensureTitleForCreate(document.fields);
2223
2332
  }
2224
2333
  const operations = toPatchOperations(document.fields, action);
2225
- const credential = await requirePat(context.org);
2334
+ const credential = await requireAuthCredential(context.org);
2226
2335
  let writeResult;
2227
2336
  if (action === "created") {
2228
- writeResult = await createWorkItem(context, createType, credential.pat, operations);
2337
+ writeResult = await createWorkItem(context, createType, credential, operations);
2229
2338
  } else {
2230
2339
  if (id === void 0) {
2231
2340
  fail2("Work item ID is required for updates.");
2232
2341
  }
2233
- writeResult = await applyWorkItemPatch(context, id, credential.pat, operations);
2342
+ writeResult = await applyWorkItemPatch(context, id, credential, operations);
2234
2343
  }
2235
2344
  const result = buildUpsertResult(
2236
2345
  action,
@@ -2288,8 +2397,8 @@ function createListFieldsCommand() {
2288
2397
  let context;
2289
2398
  try {
2290
2399
  context = resolveContext(options);
2291
- const credential = await requirePat(context.org);
2292
- const fields = await getWorkItemFields(context, id, credential.pat);
2400
+ const credential = await requireAuthCredential(context.org);
2401
+ const fields = await getWorkItemFields(context, id, credential);
2293
2402
  if (options.json) {
2294
2403
  process.stdout.write(JSON.stringify({ id, fields }, null, 2) + "\n");
2295
2404
  } else {
@@ -2343,9 +2452,9 @@ function mapPullRequest(repo, pullRequest) {
2343
2452
  url: pullRequest._links?.web?.href ?? null
2344
2453
  };
2345
2454
  }
2346
- function mapPullRequestCheckName(status) {
2347
- const genre = status.context?.genre?.trim();
2348
- const name = status.context?.name?.trim();
2455
+ function mapPullRequestCheckName(status2) {
2456
+ const genre = status2.context?.genre?.trim();
2457
+ const name = status2.context?.name?.trim();
2349
2458
  if (genre && name) {
2350
2459
  return `${genre}/${name}`;
2351
2460
  }
@@ -2355,21 +2464,21 @@ function mapPullRequestCheckName(status) {
2355
2464
  if (genre) {
2356
2465
  return genre;
2357
2466
  }
2358
- return `Status #${status.id}`;
2467
+ return `Status #${status2.id}`;
2359
2468
  }
2360
- function mapPullRequestCheck(status) {
2361
- if (status.state === "notApplicable" || status.state === "notSet") {
2469
+ function mapPullRequestCheck(status2) {
2470
+ if (status2.state === "notApplicable" || status2.state === "notSet") {
2362
2471
  return null;
2363
2472
  }
2364
2473
  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
2474
+ id: status2.id,
2475
+ state: status2.state,
2476
+ name: mapPullRequestCheckName(status2),
2477
+ description: status2.description ?? null,
2478
+ targetUrl: status2.targetUrl ?? null,
2479
+ createdBy: status2.createdBy?.displayName ?? null,
2480
+ createdAt: status2.creationDate ?? null,
2481
+ updatedAt: status2.updatedDate ?? null
2373
2482
  };
2374
2483
  }
2375
2484
  function mapComment(comment) {
@@ -2405,8 +2514,8 @@ function toActiveCommentThread(thread) {
2405
2514
  };
2406
2515
  }
2407
2516
  var RESOLVED_THREAD_STATUSES = /* @__PURE__ */ new Set(["fixed", "wontFix", "closed", "byDesign"]);
2408
- function isThreadResolved(status) {
2409
- return RESOLVED_THREAD_STATUSES.has(status);
2517
+ function isThreadResolved(status2) {
2518
+ return RESOLVED_THREAD_STATUSES.has(status2);
2410
2519
  }
2411
2520
  async function readJsonResponse(response) {
2412
2521
  if (!response.ok) {
@@ -2414,7 +2523,7 @@ async function readJsonResponse(response) {
2414
2523
  }
2415
2524
  return response.json();
2416
2525
  }
2417
- async function patchThreadStatus(context, repo, pat, prId, threadId, status) {
2526
+ async function patchThreadStatus(context, repo, cred, prId, threadId, status2) {
2418
2527
  const url = new URL(
2419
2528
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}/threads/${threadId}`
2420
2529
  );
@@ -2422,41 +2531,41 @@ async function patchThreadStatus(context, repo, pat, prId, threadId, status) {
2422
2531
  const response = await fetchWithErrors(url.toString(), {
2423
2532
  method: "PATCH",
2424
2533
  headers: {
2425
- ...authHeaders(pat),
2534
+ ...authHeaders(cred),
2426
2535
  "Content-Type": "application/json"
2427
2536
  },
2428
- body: JSON.stringify({ status })
2537
+ body: JSON.stringify({ status: status2 })
2429
2538
  });
2430
2539
  const data = await readJsonResponse(response);
2431
2540
  return toActiveCommentThread(data);
2432
2541
  }
2433
- async function getPullRequestById(context, repo, pat, prId) {
2542
+ async function getPullRequestById(context, repo, cred, prId) {
2434
2543
  const url = new URL(
2435
2544
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}`
2436
2545
  );
2437
2546
  url.searchParams.set("api-version", "7.1");
2438
- const response = await fetchWithErrors(url.toString(), { headers: authHeaders(pat) });
2547
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
2439
2548
  const data = await readJsonResponse(response);
2440
2549
  return mapPullRequest(repo, data);
2441
2550
  }
2442
- async function listPullRequests(context, repo, pat, sourceBranch, opts) {
2551
+ async function listPullRequests(context, repo, cred, sourceBranch, opts) {
2443
2552
  const response = await fetchWithErrors(
2444
2553
  buildPullRequestsUrl(context, repo, sourceBranch, opts).toString(),
2445
- { headers: authHeaders(pat) }
2554
+ { headers: authHeaders(cred) }
2446
2555
  );
2447
2556
  const data = await readJsonResponse(response);
2448
2557
  return data.value.map((pullRequest) => mapPullRequest(repo, pullRequest));
2449
2558
  }
2450
- async function getPullRequestChecks(context, repo, pat, prId) {
2559
+ async function getPullRequestChecks(context, repo, cred, prId) {
2451
2560
  const response = await fetchWithErrors(
2452
2561
  buildPullRequestStatusesUrl(context, repo, prId).toString(),
2453
- { headers: authHeaders(pat) }
2562
+ { headers: authHeaders(cred) }
2454
2563
  );
2455
2564
  const data = await readJsonResponse(response);
2456
2565
  return data.value.map(mapPullRequestCheck).filter((check) => check !== null);
2457
2566
  }
2458
- async function openPullRequest(context, repo, pat, sourceBranch, title, description) {
2459
- const existing = await listPullRequests(context, repo, pat, sourceBranch, {
2567
+ async function openPullRequest(context, repo, cred, sourceBranch, title, description) {
2568
+ const existing = await listPullRequests(context, repo, cred, sourceBranch, {
2460
2569
  status: "active",
2461
2570
  targetBranch: "develop"
2462
2571
  });
@@ -2484,7 +2593,7 @@ async function openPullRequest(context, repo, pat, sourceBranch, title, descript
2484
2593
  const response = await fetchWithErrors(url.toString(), {
2485
2594
  method: "POST",
2486
2595
  headers: {
2487
- ...authHeaders(pat),
2596
+ ...authHeaders(cred),
2488
2597
  "Content-Type": "application/json"
2489
2598
  },
2490
2599
  body: JSON.stringify(payload)
@@ -2497,12 +2606,12 @@ async function openPullRequest(context, repo, pat, sourceBranch, title, descript
2497
2606
  pullRequest: mapPullRequest(repo, data)
2498
2607
  };
2499
2608
  }
2500
- async function getPullRequestThreads(context, repo, pat, prId) {
2609
+ async function getPullRequestThreads(context, repo, cred, prId) {
2501
2610
  const url = new URL(
2502
2611
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}/threads`
2503
2612
  );
2504
2613
  url.searchParams.set("api-version", "7.1");
2505
- const response = await fetchWithErrors(url.toString(), { headers: authHeaders(pat) });
2614
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
2506
2615
  const data = await readJsonResponse(response);
2507
2616
  return data.value.map(mapThread).filter((thread) => thread !== null);
2508
2617
  }
@@ -2569,8 +2678,8 @@ function formatPullRequestBlock(pullRequest) {
2569
2678
  ...formatPullRequestChecks(pullRequest.checks)
2570
2679
  ].join("\n");
2571
2680
  }
2572
- function threadStatusLabel(status) {
2573
- return isThreadResolved(status) ? "resolved" : status;
2681
+ function threadStatusLabel(status2) {
2682
+ return isThreadResolved(status2) ? "resolved" : status2;
2574
2683
  }
2575
2684
  function formatThreads(prId, title, threads) {
2576
2685
  const lines = [`Comment threads for pull request #${prId}: ${title}`];
@@ -2587,12 +2696,12 @@ async function resolvePrCommandContext(options, resolveOpts = {}) {
2587
2696
  const context = resolveContext(options);
2588
2697
  const repo = detectRepoName();
2589
2698
  const branch = requireBranch ? getCurrentBranch() : null;
2590
- const credential = await requirePat(context.org);
2699
+ const credential = await requireAuthCredential(context.org);
2591
2700
  return {
2592
2701
  context,
2593
2702
  repo,
2594
2703
  branch,
2595
- pat: credential.pat
2704
+ pat: credential
2596
2705
  };
2597
2706
  }
2598
2707
  function createPrStatusCommand() {
@@ -2915,8 +3024,8 @@ function createCommentsListCommand() {
2915
3024
  let context;
2916
3025
  try {
2917
3026
  context = resolveContext(options);
2918
- const credential = await requirePat(context.org);
2919
- const result = await listWorkItemComments(context, id, credential.pat);
3027
+ const credential = await requireAuthCredential(context.org);
3028
+ const result = await listWorkItemComments(context, id, credential);
2920
3029
  if (options.json) {
2921
3030
  process.stdout.write(`${JSON.stringify(result, null, 2)}
2922
3031
  `);
@@ -2946,9 +3055,9 @@ function createCommentsAddCommand() {
2946
3055
  let context;
2947
3056
  try {
2948
3057
  context = resolveContext(options);
2949
- const credential = await requirePat(context.org);
3058
+ const credential = await requireAuthCredential(context.org);
2950
3059
  const format = options.markdown === true ? "markdown" : "html";
2951
- const result = await addWorkItemComment(context, id, credential.pat, text, format);
3060
+ const result = await addWorkItemComment(context, id, credential, text, format);
2952
3061
  if (options.json) {
2953
3062
  process.stdout.write(`${JSON.stringify(result, null, 2)}
2954
3063
  `);
@@ -2984,14 +3093,14 @@ function createDownloadAttachmentCommand() {
2984
3093
  let context;
2985
3094
  try {
2986
3095
  context = resolveContext(options);
2987
- const credential = await requirePat(context.org);
3096
+ const credential = await requireAuthCredential(context.org);
2988
3097
  const outputDir = options.output ?? ".";
2989
3098
  if (!existsSync4(outputDir)) {
2990
3099
  process.stderr.write(`Error: Output directory "${outputDir}" does not exist.
2991
3100
  `);
2992
3101
  process.exit(1);
2993
3102
  }
2994
- const workItem = await getWorkItem(context, id, credential.pat);
3103
+ const workItem = await getWorkItem(context, id, credential);
2995
3104
  const attachment = workItem.attachments?.find(
2996
3105
  (a) => a.name === filename
2997
3106
  );
@@ -3002,7 +3111,7 @@ function createDownloadAttachmentCommand() {
3002
3111
  );
3003
3112
  process.exit(1);
3004
3113
  }
3005
- const data = await downloadAttachment(attachment.url, credential.pat);
3114
+ const data = await downloadAttachment(attachment.url, credential);
3006
3115
  const outputPath = join2(outputDir, filename);
3007
3116
  await writeFile(outputPath, Buffer.from(data));
3008
3117
  process.stdout.write(