azdo-cli 0.10.1 → 0.12.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,7 +1,44 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ AZDO_RESOURCE_ID,
4
+ CredentialMissingError,
5
+ CredentialStoreUnavailableError,
6
+ SETTINGS,
7
+ appendAuthAuditEvent,
8
+ buildScopeString,
9
+ copyOrgScope,
10
+ defaultScopes,
11
+ deleteOrgScope,
12
+ deletePat,
13
+ firstPartyShippedScopes,
14
+ getConfigValue,
15
+ getOrgScopedValue,
16
+ getPat,
17
+ getStoredCredential,
18
+ listOrgsWithStoredPat,
19
+ loadConfig,
20
+ maskedDisplay,
21
+ moveOrgScope,
22
+ normalizePat,
23
+ openUrl,
24
+ probeBackend,
25
+ readAuditEvents,
26
+ readTokenResponse,
27
+ refreshIfNeeded,
28
+ resolveOAuthConfig,
29
+ resolveScopedConfig,
30
+ runAuthCodeFlow,
31
+ setConfigValue,
32
+ setOrgScopedValue,
33
+ storeOAuthCredential,
34
+ storePat,
35
+ tokenResponseToCredential,
36
+ unsetConfigValue,
37
+ unsetOrgScopedValue
38
+ } from "./chunk-XVXMDWQE.js";
2
39
 
3
40
  // src/index.ts
4
- import { Command as Command15 } from "commander";
41
+ import { Command as Command17 } from "commander";
5
42
 
6
43
  // src/version.ts
7
44
  import { readFileSync } from "fs";
@@ -26,8 +63,15 @@ var DEFAULT_FIELDS = [
26
63
  "System.AreaPath",
27
64
  "System.IterationPath"
28
65
  ];
29
- function authHeaders(pat) {
30
- const token = Buffer.from(`:${pat}`).toString("base64");
66
+ function authHeaders(credentialOrPat) {
67
+ if (typeof credentialOrPat === "string") {
68
+ const token2 = Buffer.from(`:${credentialOrPat}`).toString("base64");
69
+ return { Authorization: `Basic ${token2}` };
70
+ }
71
+ if (credentialOrPat.kind === "oauth") {
72
+ return { Authorization: `Bearer ${credentialOrPat.pat}` };
73
+ }
74
+ const token = Buffer.from(`:${credentialOrPat.pat}`).toString("base64");
31
75
  return { Authorization: `Basic ${token}` };
32
76
  }
33
77
  async function fetchWithErrors(url, init) {
@@ -48,6 +92,10 @@ async function fetchWithErrors(url, init) {
48
92
  }
49
93
  throw new Error(`NOT_FOUND${detail}`);
50
94
  }
95
+ const contentType = response.headers?.get("content-type") ?? "";
96
+ if (contentType.toLowerCase().startsWith("text/html")) {
97
+ throw new Error("AUTH_FAILED");
98
+ }
51
99
  return response;
52
100
  }
53
101
  async function readResponseMessage(response) {
@@ -90,9 +138,9 @@ function buildExtraFields(fields, requested) {
90
138
  }
91
139
  return Object.keys(result).length > 0 ? result : null;
92
140
  }
93
- function writeHeaders(pat) {
141
+ function writeHeaders(cred) {
94
142
  return {
95
- ...authHeaders(pat),
143
+ ...authHeaders(cred),
96
144
  "Content-Type": "application/json-patch+json"
97
145
  };
98
146
  }
@@ -147,13 +195,13 @@ async function readWriteResponse(response, errorCode) {
147
195
  fields: data.fields
148
196
  };
149
197
  }
150
- async function getWorkItemFields(context, id, pat) {
198
+ async function getWorkItemFields(context, id, cred) {
151
199
  const url = new URL(
152
200
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
153
201
  );
154
202
  url.searchParams.set("api-version", "7.1");
155
203
  url.searchParams.set("$expand", "all");
156
- const response = await fetchWithErrors(url.toString(), { headers: authHeaders(pat) });
204
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
157
205
  if (response.status === 400) {
158
206
  const serverMessage = await readResponseMessage(response);
159
207
  if (serverMessage) {
@@ -188,10 +236,10 @@ function buildWorkItemUrl(context, id, options = {}) {
188
236
  }
189
237
  return url;
190
238
  }
191
- async function fetchWorkItemResponse(context, id, pat, options = {}) {
239
+ async function fetchWorkItemResponse(context, id, cred, options = {}) {
192
240
  const response = await fetchWithErrors(
193
241
  buildWorkItemUrl(context, id, options).toString(),
194
- { headers: authHeaders(pat) }
242
+ { headers: authHeaders(cred) }
195
243
  );
196
244
  if (response.status === 400) {
197
245
  const serverMessage = await readResponseMessage(response);
@@ -204,28 +252,66 @@ async function fetchWorkItemResponse(context, id, pat, options = {}) {
204
252
  }
205
253
  return await response.json();
206
254
  }
207
- async function getWorkItem(context, id, pat, extraFields) {
255
+ async function getOrgFieldNames(context, cred) {
256
+ const url = new URL(
257
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/_apis/wit/fields`
258
+ );
259
+ url.searchParams.set("api-version", "7.1");
260
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
261
+ if (!response.ok) {
262
+ throw new Error(`HTTP_${response.status}`);
263
+ }
264
+ const data = await response.json();
265
+ return (data.value ?? []).map((f) => f.referenceName);
266
+ }
267
+ function buildCombinedDescription(fields) {
268
+ const parts = [];
269
+ if (fields["System.Description"]) {
270
+ parts.push({ label: "Description", value: fields["System.Description"] });
271
+ }
272
+ if (fields["Microsoft.VSTS.Common.AcceptanceCriteria"]) {
273
+ parts.push({ label: "Acceptance Criteria", value: fields["Microsoft.VSTS.Common.AcceptanceCriteria"] });
274
+ }
275
+ if (fields["Microsoft.VSTS.TCM.ReproSteps"]) {
276
+ parts.push({ label: "Repro Steps", value: fields["Microsoft.VSTS.TCM.ReproSteps"] });
277
+ }
278
+ if (parts.length === 0) return null;
279
+ if (parts.length === 1) return parts[0].value;
280
+ return parts.map((p) => `<h3>${p.label}</h3>${p.value}`).join("");
281
+ }
282
+ async function fetchWorkItemWithFallback(context, id, cred, normalizedExtraFields) {
283
+ try {
284
+ const data = await fetchWorkItemResponse(context, id, cred, {
285
+ fields: normalizeFieldList([...DEFAULT_FIELDS, ...normalizedExtraFields])
286
+ });
287
+ return { data, effectiveExtraFields: normalizedExtraFields };
288
+ } catch (err) {
289
+ const msg = err instanceof Error ? err.message : String(err);
290
+ if (!msg.includes("TF51535")) throw err;
291
+ const orgFieldNames = await getOrgFieldNames(context, cred);
292
+ const orgFieldsLower = new Set(orgFieldNames.map((n) => n.toLowerCase()));
293
+ const missing = normalizedExtraFields.filter((f) => !orgFieldsLower.has(f.toLowerCase()));
294
+ const effectiveExtraFields = normalizedExtraFields.filter((f) => orgFieldsLower.has(f.toLowerCase()));
295
+ for (const f of missing) {
296
+ process.stderr.write(`azdo: warning: field '${f}' does not exist in organization '${context.org}' and was skipped
297
+ `);
298
+ }
299
+ const data = await fetchWorkItemResponse(context, id, cred, {
300
+ fields: normalizeFieldList([...DEFAULT_FIELDS, ...effectiveExtraFields])
301
+ });
302
+ return { data, effectiveExtraFields };
303
+ }
304
+ }
305
+ async function getWorkItem(context, id, cred, extraFields) {
208
306
  const normalizedExtraFields = extraFields ? normalizeFieldList(extraFields) : [];
209
- const data = normalizedExtraFields.length > 0 ? await fetchWorkItemResponse(context, id, pat, {
210
- fields: normalizeFieldList([...DEFAULT_FIELDS, ...normalizedExtraFields])
211
- }) : await fetchWorkItemResponse(context, id, pat, { includeRelations: true });
212
- const relationsData = normalizedExtraFields.length > 0 ? await fetchWorkItemResponse(context, id, pat, { includeRelations: true }) : data;
213
- const descriptionParts = [];
214
- if (data.fields["System.Description"]) {
215
- descriptionParts.push({ label: "Description", value: data.fields["System.Description"] });
216
- }
217
- if (data.fields["Microsoft.VSTS.Common.AcceptanceCriteria"]) {
218
- descriptionParts.push({ label: "Acceptance Criteria", value: data.fields["Microsoft.VSTS.Common.AcceptanceCriteria"] });
219
- }
220
- if (data.fields["Microsoft.VSTS.TCM.ReproSteps"]) {
221
- descriptionParts.push({ label: "Repro Steps", value: data.fields["Microsoft.VSTS.TCM.ReproSteps"] });
222
- }
223
- let combinedDescription = null;
224
- if (descriptionParts.length === 1) {
225
- combinedDescription = descriptionParts.at(0)?.value ?? null;
226
- } else if (descriptionParts.length > 1) {
227
- combinedDescription = descriptionParts.map((p) => `<h3>${p.label}</h3>${p.value}`).join("");
307
+ let effectiveExtraFields = normalizedExtraFields;
308
+ let data;
309
+ if (normalizedExtraFields.length > 0) {
310
+ ({ data, effectiveExtraFields } = await fetchWorkItemWithFallback(context, id, cred, normalizedExtraFields));
311
+ } else {
312
+ data = await fetchWorkItemResponse(context, id, cred, { includeRelations: true });
228
313
  }
314
+ const relationsData = normalizedExtraFields.length > 0 ? await fetchWorkItemResponse(context, id, cred, { includeRelations: true }) : data;
229
315
  return {
230
316
  id: data.id,
231
317
  rev: data.rev,
@@ -233,21 +319,21 @@ async function getWorkItem(context, id, pat, extraFields) {
233
319
  state: data.fields["System.State"],
234
320
  type: data.fields["System.WorkItemType"],
235
321
  assignedTo: data.fields["System.AssignedTo"]?.displayName ?? null,
236
- description: combinedDescription,
322
+ description: buildCombinedDescription(data.fields),
237
323
  areaPath: data.fields["System.AreaPath"],
238
324
  iterationPath: data.fields["System.IterationPath"],
239
325
  url: data._links.html.href,
240
- extraFields: normalizedExtraFields.length > 0 ? buildExtraFields(data.fields, normalizedExtraFields) : null,
326
+ extraFields: effectiveExtraFields.length > 0 ? buildExtraFields(data.fields, effectiveExtraFields) : null,
241
327
  attachments: extractAttachments(relationsData.relations)
242
328
  };
243
329
  }
244
- async function getWorkItemFieldValue(context, id, pat, fieldName) {
330
+ async function getWorkItemFieldValue(context, id, cred, fieldName) {
245
331
  const url = new URL(
246
332
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
247
333
  );
248
334
  url.searchParams.set("api-version", "7.1");
249
335
  url.searchParams.set("fields", fieldName);
250
- const response = await fetchWithErrors(url.toString(), { headers: authHeaders(pat) });
336
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
251
337
  if (response.status === 400) {
252
338
  const serverMessage = await readResponseMessage(response);
253
339
  if (serverMessage) {
@@ -264,13 +350,13 @@ async function getWorkItemFieldValue(context, id, pat, fieldName) {
264
350
  }
265
351
  return stringifyFieldValue(value);
266
352
  }
267
- async function listWorkItemComments(context, id, pat) {
353
+ async function listWorkItemComments(context, id, cred) {
268
354
  const comments = [];
269
355
  let continuationToken = null;
270
356
  do {
271
357
  const response = await fetchWithErrors(
272
358
  buildWorkItemCommentsListUrl(context, id, continuationToken ?? void 0).toString(),
273
- { headers: authHeaders(pat) }
359
+ { headers: authHeaders(cred) }
274
360
  );
275
361
  if (!response.ok) {
276
362
  throw new Error(`HTTP_${response.status}`);
@@ -287,13 +373,13 @@ async function listWorkItemComments(context, id, pat) {
287
373
  comments
288
374
  };
289
375
  }
290
- async function addWorkItemComment(context, id, pat, text, format = "html") {
376
+ async function addWorkItemComment(context, id, cred, text, format = "html") {
291
377
  const url = buildWorkItemCommentsUrl(context, id);
292
378
  url.searchParams.set("format", format);
293
379
  const response = await fetchWithErrors(url.toString(), {
294
380
  method: "POST",
295
381
  headers: {
296
- ...authHeaders(pat),
382
+ ...authHeaders(cred),
297
383
  "Content-Type": "application/json"
298
384
  },
299
385
  body: JSON.stringify({ text })
@@ -315,8 +401,8 @@ async function addWorkItemComment(context, id, pat, text, format = "html") {
315
401
  url: data.url ?? null
316
402
  };
317
403
  }
318
- async function updateWorkItem(context, id, pat, fieldName, operations) {
319
- const result = await applyWorkItemPatch(context, id, pat, operations);
404
+ async function updateWorkItem(context, id, cred, fieldName, operations) {
405
+ const result = await applyWorkItemPatch(context, id, cred, operations);
320
406
  const title = result.fields["System.Title"];
321
407
  const lastOp = operations.at(-1);
322
408
  const fieldValue = lastOp?.value ?? null;
@@ -328,32 +414,32 @@ async function updateWorkItem(context, id, pat, fieldName, operations) {
328
414
  fieldValue
329
415
  };
330
416
  }
331
- async function createWorkItem(context, workItemType, pat, operations) {
417
+ async function createWorkItem(context, workItemType, cred, operations) {
332
418
  const url = new URL(
333
419
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/$${encodeURIComponent(workItemType)}`
334
420
  );
335
421
  url.searchParams.set("api-version", "7.1");
336
422
  const response = await fetchWithErrors(url.toString(), {
337
423
  method: "POST",
338
- headers: writeHeaders(pat),
424
+ headers: writeHeaders(cred),
339
425
  body: JSON.stringify(operations)
340
426
  });
341
427
  return readWriteResponse(response, "CREATE_REJECTED");
342
428
  }
343
- async function applyWorkItemPatch(context, id, pat, operations) {
429
+ async function applyWorkItemPatch(context, id, cred, operations) {
344
430
  const url = new URL(
345
431
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
346
432
  );
347
433
  url.searchParams.set("api-version", "7.1");
348
434
  const response = await fetchWithErrors(url.toString(), {
349
435
  method: "PATCH",
350
- headers: writeHeaders(pat),
436
+ headers: writeHeaders(cred),
351
437
  body: JSON.stringify(operations)
352
438
  });
353
439
  return readWriteResponse(response, "UPDATE_REJECTED");
354
440
  }
355
- async function downloadAttachment(url, pat) {
356
- const response = await fetchWithErrors(url, { headers: authHeaders(pat) });
441
+ async function downloadAttachment(url, cred) {
442
+ const response = await fetchWithErrors(url, { headers: authHeaders(cred) });
357
443
  if (!response.ok) {
358
444
  throw new Error(`HTTP_${response.status}`);
359
445
  }
@@ -365,323 +451,124 @@ import { createInterface } from "readline";
365
451
  import { existsSync, readFileSync as readFileSync2 } from "fs";
366
452
  import { dirname as dirname2, join } from "path";
367
453
 
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;
454
+ // src/services/oauth-device-code.ts
455
+ var DeviceCodeFlowError = class extends Error {
456
+ reason;
457
+ constructor(reason, message, cause) {
458
+ super(message);
459
+ this.name = "DeviceCodeFlowError";
460
+ this.reason = reason;
378
461
  if (cause instanceof Error) {
379
462
  this.cause = cause;
380
463
  }
381
464
  }
382
465
  };
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
- }
466
+ var MIN_INTERVAL_SEC = 5;
467
+ async function defaultSleep(ms) {
468
+ await new Promise((r) => setTimeout(r, ms));
469
+ }
470
+ async function requestDeviceCode(oauthConfig, fetchFn) {
471
+ const body = new URLSearchParams({
472
+ client_id: oauthConfig.clientId,
473
+ scope: buildScopeString(oauthConfig.scopes)
474
+ });
475
+ const response = await fetchFn(oauthConfig.deviceCodeEndpoint, {
476
+ method: "POST",
477
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
478
+ body: body.toString()
479
+ });
480
+ const text = await response.text();
481
+ let parsed;
396
482
  try {
397
- fs.chmodSync(dir, 448);
483
+ parsed = JSON.parse(text);
398
484
  } catch {
485
+ throw new DeviceCodeFlowError("idp-error", `device-code endpoint returned non-JSON HTTP ${response.status}: ${text.slice(0, 200)}`);
399
486
  }
487
+ if (!response.ok) {
488
+ const err = parsed;
489
+ const code = err.error ?? "unknown";
490
+ const desc = err.error_description ? `: ${err.error_description}` : "";
491
+ throw new DeviceCodeFlowError(
492
+ "idp-error",
493
+ `device-code endpoint rejected request (${response.status}): ${code}${desc}`
494
+ );
495
+ }
496
+ return parsed;
400
497
  }
401
- function ensureFileWithPerms(file) {
402
- if (!fs.existsSync(file)) {
403
- fs.writeFileSync(file, "", { mode: 384 });
404
- return;
498
+ async function classifyDeviceTokenResponse(response) {
499
+ if (response.ok) {
500
+ return { kind: "success", token: await readTokenResponse(response) };
405
501
  }
502
+ const text = await response.text();
503
+ let parsed;
406
504
  try {
407
- fs.chmodSync(file, 384);
505
+ parsed = JSON.parse(text);
408
506
  } catch {
507
+ throw new DeviceCodeFlowError("idp-error", `non-JSON HTTP ${response.status}: ${text.slice(0, 200)}`);
409
508
  }
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
- }
509
+ const errCode = parsed.error ?? "";
510
+ if (errCode === "authorization_pending") return { kind: "pending" };
511
+ if (errCode === "slow_down") return { kind: "slow_down" };
512
+ if (errCode === "expired_token") {
513
+ throw new DeviceCodeFlowError("expired_token", "device code expired before authorisation completed");
443
514
  }
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
515
+ if (errCode === "access_denied") {
516
+ throw new DeviceCodeFlowError("access_denied", "authorisation denied by user");
479
517
  }
480
- ];
481
- var VALID_KEYS = SETTINGS.map((s) => s.key);
482
- function getConfigPath() {
483
- return path2.join(os2.homedir(), ".azdo", "config.json");
518
+ const desc = parsed.error_description ? `: ${parsed.error_description}` : "";
519
+ throw new DeviceCodeFlowError(
520
+ "idp-error",
521
+ `IdP rejected device-token poll (${response.status}): ${errCode}${desc}`
522
+ );
484
523
  }
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 {};
524
+ async function pollForDeviceToken(deviceCode, oauthConfig, initialIntervalSec, expiresAtMs, deps) {
525
+ const fetchFn = deps.fetch ?? fetch;
526
+ const now = deps.now ?? (() => Date.now());
527
+ const sleep2 = deps.sleep ?? defaultSleep;
528
+ let intervalSec = Math.max(MIN_INTERVAL_SEC, initialIntervalSec);
529
+ for (; ; ) {
530
+ if (now() >= expiresAtMs) {
531
+ throw new DeviceCodeFlowError("expired_token", "device-code flow expired before authorisation completed");
493
532
  }
494
- throw err;
495
- }
496
- try {
497
- return JSON.parse(raw);
498
- } catch {
499
- process.stderr.write(`Warning: Config file ${configPath} contains invalid JSON. Using defaults.
500
- `);
501
- return {};
502
- }
503
- }
504
- function saveConfig(config) {
505
- const configPath = getConfigPath();
506
- const dir = path2.dirname(configPath);
507
- fs2.mkdirSync(dir, { recursive: true });
508
- fs2.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
509
- }
510
- function validateKey(key) {
511
- if (!VALID_KEYS.includes(key)) {
512
- throw new Error(`Unknown setting key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
513
- }
514
- }
515
- function getConfigValue(key) {
516
- validateKey(key);
517
- const config = loadConfig();
518
- return config[key];
519
- }
520
- function setConfigValue(key, value) {
521
- validateKey(key);
522
- const config = loadConfig();
523
- if (value === "") {
524
- delete config[key];
525
- } else if (key === "markdown") {
526
- if (value !== "true" && value !== "false") {
527
- throw new Error(`Invalid value "${value}" for markdown. Must be "true" or "false".`);
533
+ await sleep2(intervalSec * 1e3);
534
+ const body = new URLSearchParams({
535
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
536
+ client_id: oauthConfig.clientId,
537
+ device_code: deviceCode
538
+ });
539
+ const response = await fetchFn(oauthConfig.tokenEndpoint, {
540
+ method: "POST",
541
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
542
+ body: body.toString()
543
+ });
544
+ const outcome = await classifyDeviceTokenResponse(response);
545
+ if (outcome.kind === "success") return outcome.token;
546
+ if (outcome.kind === "slow_down") {
547
+ intervalSec += 5;
528
548
  }
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
549
  }
535
- saveConfig(config);
536
- }
537
- function unsetConfigValue(key) {
538
- validateKey(key);
539
- const config = loadConfig();
540
- delete config[key];
541
- saveConfig(config);
542
550
  }
551
+ async function runDeviceCodeFlow(org, oauthConfig, deps = {}) {
552
+ const fetchFn = deps.fetch ?? fetch;
553
+ const now = deps.now ?? (() => Date.now());
554
+ const writePrompt = deps.writePrompt ?? ((m) => {
555
+ process.stderr.write(m);
556
+ });
557
+ const dc = await requestDeviceCode(oauthConfig, fetchFn);
558
+ const expiryMin = Math.round(dc.expires_in / 60);
559
+ writePrompt(
560
+ `
561
+ To authenticate, open ${dc.verification_uri} in a browser and enter the code:
543
562
 
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
- }
563
+ ${dc.user_code}
557
564
 
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'
565
+ Waiting for authorisation (expires in ${expiryMin} min)\u2026
566
+ `
589
567
  );
590
- }
591
- async function maybeMigrateLegacy(targetOrg) {
592
- const config = loadConfig();
593
- if (!config.org || config.org !== targetOrg) {
594
- if (!config.org) {
595
- let legacyExists;
596
- try {
597
- const legacyEntry2 = new Entry(SERVICE, LEGACY_ACCOUNT);
598
- legacyExists = legacyEntry2.getPassword() !== null;
599
- } catch {
600
- legacyExists = false;
601
- }
602
- if (legacyExists) {
603
- emitLegacyUnsetNoticeOnce();
604
- }
605
- }
606
- return null;
607
- }
608
- const newEntry = new Entry(SERVICE, accountFor(targetOrg));
609
- const existingNew = wrapUnavailable(() => newEntry.getPassword());
610
- if (existingNew !== null) {
611
- return null;
612
- }
613
- const legacyEntry = new Entry(SERVICE, LEGACY_ACCOUNT);
614
- const legacy = wrapUnavailable(() => legacyEntry.getPassword());
615
- if (legacy === null) {
616
- return null;
617
- }
618
- wrapUnavailable(() => {
619
- newEntry.setPassword(legacy);
620
- legacyEntry.deletePassword();
621
- });
622
- appendAuthAuditEvent({
623
- event: "auth.store",
624
- org: targetOrg,
625
- backend: probeBackend(),
626
- masked_pat: maskedDisplay(legacy)
627
- });
628
- process.stderr.write(`Migrated legacy PAT to org ${targetOrg}.
629
- `);
630
- return legacy;
631
- }
632
- async function getPat(org) {
633
- const entry = new Entry(SERVICE, accountFor(org));
634
- const value = wrapUnavailable(() => entry.getPassword());
635
- if (value !== null) {
636
- return value;
637
- }
638
- const migrated = await maybeMigrateLegacy(org);
639
- return migrated;
640
- }
641
- async function storePat(org, pat) {
642
- const entry = new Entry(SERVICE, accountFor(org));
643
- wrapUnavailable(() => entry.setPassword(pat));
644
- appendAuthAuditEvent({
645
- event: "auth.store",
646
- org,
647
- backend: probeBackend(),
648
- masked_pat: maskedDisplay(pat)
649
- });
650
- }
651
- async function deletePat(org) {
652
- const entry = new Entry(SERVICE, accountFor(org));
653
- const existing = wrapUnavailable(() => entry.getPassword());
654
- if (existing === null) {
655
- return false;
656
- }
657
- wrapUnavailable(() => entry.deletePassword());
658
- appendAuthAuditEvent({
659
- event: "auth.delete",
660
- org,
661
- backend: probeBackend(),
662
- masked_pat: maskedDisplay(existing)
663
- });
664
- return true;
665
- }
666
- async function listOrgsWithStoredPat() {
667
- const seen = /* @__PURE__ */ new Set();
668
- for (const ev of readAuditEvents()) {
669
- if (ev.event === "auth.store") {
670
- seen.add(ev.org);
671
- } else if (ev.event === "auth.delete") {
672
- seen.delete(ev.org);
673
- }
674
- }
675
- const present = [];
676
- for (const org of seen) {
677
- const entry = new Entry(SERVICE, accountFor(org));
678
- const value = wrapUnavailable(() => entry.getPassword());
679
- if (value !== null) {
680
- present.push(org);
681
- }
682
- }
683
- present.sort((a, b) => a.localeCompare(b));
684
- return present;
568
+ const expiresAtMs = now() + dc.expires_in * 1e3;
569
+ const token = await pollForDeviceToken(dc.device_code, oauthConfig, dc.interval, expiresAtMs, deps);
570
+ const credential = tokenResponseToCredential(org, oauthConfig, token, now());
571
+ return { credential, flowUsed: "device-code" };
685
572
  }
686
573
 
687
574
  // src/services/auth.ts
@@ -750,29 +637,36 @@ function findDotEnvPat(startDir = process.cwd()) {
750
637
  }
751
638
  return null;
752
639
  }
753
- async function resolvePat(org) {
640
+ async function resolveAuthCredential(org) {
754
641
  const envPat = process.env.AZDO_PAT;
755
642
  if (envPat && envPat.length > 0) {
756
- return { pat: envPat, source: "env" };
643
+ return { pat: envPat, source: "env", kind: "pat" };
757
644
  }
758
- const storedPat = await getPat(org);
759
- if (storedPat !== null) {
760
- return { pat: storedPat, source: "credential-store" };
645
+ const stored = await getStoredCredential(org);
646
+ if (stored !== null) {
647
+ if (stored.kind === "pat") {
648
+ return { pat: stored.token, source: "credential-store", kind: "pat" };
649
+ }
650
+ const fresh = await refreshIfNeeded(org, stored);
651
+ return {
652
+ pat: fresh.accessToken,
653
+ source: "credential-store",
654
+ kind: "oauth",
655
+ accountId: fresh.accountId
656
+ };
761
657
  }
762
658
  const dotEnvPat = findDotEnvPat();
763
659
  if (dotEnvPat !== null) {
764
- return { pat: dotEnvPat, source: "env" };
660
+ return { pat: dotEnvPat, source: "env", kind: "pat" };
765
661
  }
766
662
  return null;
767
663
  }
768
- async function requirePat(org) {
769
- const cred = await resolvePat(org);
664
+ async function requireAuthCredential(org) {
665
+ const cred = await resolveAuthCredential(org);
770
666
  if (cred !== null) {
771
667
  return cred;
772
668
  }
773
- throw new Error(
774
- `No PAT available for org "${org}". Set AZDO_PAT environment variable or run \`azdo auth --org ${org}\`.`
775
- );
669
+ throw new CredentialMissingError(org);
776
670
  }
777
671
  async function validatePatAgainstAzdo(pat, org) {
778
672
  const url = `https://dev.azure.com/${encodeURIComponent(org)}/_apis/projects?$top=1&api-version=7.1`;
@@ -791,51 +685,309 @@ async function validatePatAgainstAzdo(pat, org) {
791
685
  }
792
686
  throw new Error(`Azure DevOps returned HTTP ${response.status} while validating PAT for org "${org}".`);
793
687
  }
688
+ var LOGIN_FAILURE_REASONS = /* @__PURE__ */ new Set([
689
+ "user-cancelled",
690
+ "port-conflict",
691
+ "state-mismatch",
692
+ "redirect-mismatch",
693
+ "idp-error",
694
+ "timeout",
695
+ "expired_token",
696
+ "access_denied"
697
+ ]);
698
+ function extractLoginFailureReason(err) {
699
+ if (typeof err === "object" && err !== null && "reason" in err) {
700
+ const r = err.reason;
701
+ if (typeof r === "string" && LOGIN_FAILURE_REASONS.has(r)) {
702
+ return r;
703
+ }
704
+ }
705
+ return "unknown";
706
+ }
707
+ async function loginWithOAuth(org, opts = {}) {
708
+ const oauthConfig = resolveOAuthConfig({
709
+ clientIdOverride: opts.clientIdOverride,
710
+ tenantIdOverride: opts.tenantIdOverride,
711
+ scopesOverride: opts.scopesOverride
712
+ });
713
+ const isHeadlessRuntime = () => {
714
+ if (opts.forceHeadless) return true;
715
+ if (process.platform === "linux") {
716
+ return !process.env.DISPLAY || process.env.DISPLAY.length === 0;
717
+ }
718
+ return false;
719
+ };
720
+ const useDeviceCode = opts.flow === "device-code" || opts.flow !== "auth-code" && isHeadlessRuntime();
721
+ appendAuthAuditEvent({
722
+ event: "oauth-login-started",
723
+ org,
724
+ backend: probeBackend(),
725
+ flow: useDeviceCode ? "device-code" : "auth-code",
726
+ clientIdSource: oauthConfig.clientIdSource
727
+ });
728
+ let credential;
729
+ let flowUsed;
730
+ try {
731
+ if (useDeviceCode) {
732
+ const r = await runDeviceCodeFlow(org, oauthConfig);
733
+ credential = r.credential;
734
+ flowUsed = "device-code";
735
+ } else {
736
+ const r = await runAuthCodeFlow(org, oauthConfig);
737
+ credential = r.credential;
738
+ flowUsed = "auth-code";
739
+ }
740
+ } catch (err) {
741
+ appendAuthAuditEvent({
742
+ event: "oauth-login-failed",
743
+ org,
744
+ backend: probeBackend(),
745
+ flow: useDeviceCode ? "device-code" : "auth-code",
746
+ reason: extractLoginFailureReason(err)
747
+ });
748
+ throw err;
749
+ }
750
+ await storeOAuthCredential(org, credential);
751
+ appendAuthAuditEvent({
752
+ event: "oauth-login-success",
753
+ org,
754
+ backend: probeBackend(),
755
+ flow: flowUsed,
756
+ clientIdSource: oauthConfig.clientIdSource,
757
+ accountId: credential.accountId,
758
+ scope: credential.scope,
759
+ tokenLifetimeSec: credential.expiresAt - credential.issuedAt
760
+ });
761
+ return {
762
+ org,
763
+ kind: "oauth",
764
+ accountId: credential.accountId,
765
+ expiresAt: credential.expiresAt,
766
+ scope: credential.scope,
767
+ flowUsed
768
+ };
769
+ }
770
+ async function logout(opts = {}) {
771
+ if (opts.all) {
772
+ const orgs = await listOrgsWithStoredPat();
773
+ const removed = [];
774
+ for (const o of orgs) {
775
+ const cred2 = await getStoredCredential(o);
776
+ const ok2 = await deletePat(o);
777
+ if (ok2 && cred2 !== null) {
778
+ removed.push({ org: o, kind: cred2.kind });
779
+ }
780
+ }
781
+ return { removed };
782
+ }
783
+ if (!opts.org) {
784
+ throw new Error("logout requires an org or --all");
785
+ }
786
+ const cred = await getStoredCredential(opts.org);
787
+ const ok = await deletePat(opts.org);
788
+ return { removed: ok && cred !== null ? [{ org: opts.org, kind: cred.kind }] : [] };
789
+ }
790
+ async function status() {
791
+ const orgs = await listOrgsWithStoredPat();
792
+ const out = [];
793
+ for (const org of orgs) {
794
+ const cred = await getStoredCredential(org);
795
+ if (cred === null) continue;
796
+ if (cred.kind === "pat") {
797
+ out.push({ org, kind: "pat", backend: probeBackend() });
798
+ } else {
799
+ out.push({
800
+ org,
801
+ kind: "oauth",
802
+ accountId: cred.accountId,
803
+ expiresAt: cred.expiresAt,
804
+ scope: cred.scope,
805
+ backend: probeBackend()
806
+ });
807
+ }
808
+ }
809
+ return { orgs: out };
810
+ }
811
+
812
+ // src/services/git-remote.ts
813
+ import { execFileSync } from "child_process";
814
+ import fs from "fs";
815
+ import path from "path";
816
+
817
+ // src/services/remote-warning.ts
818
+ var warned = false;
819
+ function buildWarning(remoteName) {
820
+ return `azdo: warning: ${remoteName} includes embedded credentials; consider removing them with 'git remote set-url ${remoteName} <clean-url>'
821
+ `;
822
+ }
823
+ function noticeCredentialBearingRemote(remoteName = "origin") {
824
+ if (warned) {
825
+ return;
826
+ }
827
+ warned = true;
828
+ try {
829
+ process.stderr.write(buildWarning(remoteName));
830
+ } catch {
831
+ }
832
+ }
794
833
 
795
834
  // src/services/git-remote.ts
796
- import { execSync } from "child_process";
835
+ var GIT_BINARY = (() => {
836
+ const known = process.platform === "win32" ? [String.raw`C:\Program Files\Git\bin\git.exe`, String.raw`C:\Program Files (x86)\Git\bin\git.exe`] : ["/usr/bin/git", "/usr/local/bin/git", "/opt/homebrew/bin/git"];
837
+ return known.find((p) => {
838
+ try {
839
+ execFileSync(p, ["--version"], { stdio: "ignore" });
840
+ return true;
841
+ } catch {
842
+ return false;
843
+ }
844
+ }) ?? "git";
845
+ })();
797
846
  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\/([^/]+)\/([^/]+)\/([^/]+)$/
847
+ // HTTPS (current): https://[user[:token]@]dev.azure.com/{org}/{project}/_git/{repo}[.git]
848
+ /^https?:\/\/(?:[^@/]+@)?dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+?)(?:\.git)?$/,
849
+ // HTTPS (legacy + DefaultCollection): https://[user[:token]@]{org}.visualstudio.com/DefaultCollection/{project}/_git/{repo}[.git]
850
+ /^https?:\/\/(?:[^@/]+@)?([^.]+)\.visualstudio\.com\/DefaultCollection\/([^/]+)\/_git\/([^/]+?)(?:\.git)?$/,
851
+ // HTTPS (legacy): https://[user[:token]@]{org}.visualstudio.com/{project}/_git/{repo}[.git]
852
+ /^https?:\/\/(?:[^@/]+@)?([^.]+)\.visualstudio\.com\/([^/]+)\/_git\/([^/]+?)(?:\.git)?$/,
853
+ // SSH (current): git@ssh.dev.azure.com:v3/{org}/{project}/{repo}[.git]
854
+ /^git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?$/,
855
+ // SSH (legacy): {org}@vs-ssh.visualstudio.com:v3/{org}/{project}/{repo}[.git]
856
+ /^[^@]+@vs-ssh\.visualstudio\.com:v3\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?$/
808
857
  ];
809
- function parseAzdoRemote(url) {
858
+ var httpsEmbeddedSecret = /^https?:\/\/[^:@/]+:[^@/]+@/;
859
+ function parseSingleRemoteLine(line) {
860
+ const trimmed = line.trim();
861
+ if (!trimmed) return null;
862
+ const tabIdx = trimmed.indexOf(" ");
863
+ if (tabIdx === -1) return null;
864
+ const remoteName = trimmed.slice(0, tabIdx);
865
+ const afterTab = trimmed.slice(tabIdx + 1);
866
+ const urlEnd = afterTab.lastIndexOf(" (");
867
+ const url = urlEnd === -1 ? afterTab : afterTab.slice(0, urlEnd);
868
+ return { remoteName, url };
869
+ }
870
+ function matchAzdoRemote(remoteName, url) {
810
871
  for (const pattern of patterns) {
811
872
  const match = pattern.exec(url);
812
- if (match) {
813
- const project = match[2];
814
- if (/^DefaultCollection$/i.test(project)) {
815
- return { org: match[1], project: "" };
873
+ if (!match) continue;
874
+ const project = match[2];
875
+ if (/^DefaultCollection$/i.test(project)) return null;
876
+ return { remoteName, org: match[1], project, hasEmbeddedSecret: httpsEmbeddedSecret.test(url) };
877
+ }
878
+ return null;
879
+ }
880
+ function parseAllAzdoRemotes(output) {
881
+ const seen = /* @__PURE__ */ new Set();
882
+ const results = [];
883
+ for (const line of output.split("\n")) {
884
+ const parsed = parseSingleRemoteLine(line);
885
+ if (!parsed) continue;
886
+ const { remoteName, url } = parsed;
887
+ if (seen.has(remoteName)) continue;
888
+ const candidate = matchAzdoRemote(remoteName, url);
889
+ if (candidate) {
890
+ seen.add(remoteName);
891
+ results.push(candidate);
892
+ }
893
+ }
894
+ return results;
895
+ }
896
+ function selectRemote(candidates) {
897
+ if (candidates.length === 0) {
898
+ throw new Error("No Azure DevOps remote found. Provide --org and --project explicitly.");
899
+ }
900
+ const origin = candidates.find((c) => c.remoteName === "origin");
901
+ if (origin) return origin;
902
+ if (candidates.length === 1) return candidates[0];
903
+ const first = candidates[0];
904
+ const allSame = candidates.every(
905
+ (c) => c.org.toLowerCase() === first.org.toLowerCase() && c.project.toLowerCase() === first.project.toLowerCase()
906
+ );
907
+ if (allSame) return first;
908
+ const lines = candidates.map((c) => ` ${c.remoteName.padEnd(8)} \u2192 ${c.org}/${c.project}`).join("\n");
909
+ throw new Error(
910
+ `Multiple Azure DevOps remotes found with different org/project:
911
+ ${lines}
912
+ Use --org/--project (or 'git remote rename <name> origin') to disambiguate.`
913
+ );
914
+ }
915
+ function readGitConfigContent() {
916
+ const gitDirEnv = process.env.GIT_DIR;
917
+ if (gitDirEnv) {
918
+ return fs.readFileSync(path.join(gitDirEnv, "config"), "utf-8");
919
+ }
920
+ let dir = process.cwd();
921
+ for (; ; ) {
922
+ const gitPath = path.join(dir, ".git");
923
+ try {
924
+ const stat = fs.statSync(gitPath);
925
+ if (stat.isDirectory()) {
926
+ return fs.readFileSync(path.join(gitPath, "config"), "utf-8");
927
+ }
928
+ if (stat.isFile()) {
929
+ const ref = fs.readFileSync(gitPath, "utf-8");
930
+ const m = /^gitdir:[ \t]*([^\r\n]+)/m.exec(ref);
931
+ if (m) {
932
+ return fs.readFileSync(path.join(path.resolve(dir, m[1].trim()), "config"), "utf-8");
933
+ }
934
+ }
935
+ } catch {
936
+ }
937
+ const parent = path.dirname(dir);
938
+ if (parent === dir) break;
939
+ dir = parent;
940
+ }
941
+ throw new Error("Not in a git repository. Provide --org and --project explicitly.");
942
+ }
943
+ function gitConfigToRemoteLines(configContent) {
944
+ const lines = [];
945
+ let currentRemote = null;
946
+ let emittedUrl = false;
947
+ for (const line of configContent.split("\n")) {
948
+ const sectionMatch = /^\[remote\s+"([^"]+)"\]/.exec(line);
949
+ if (sectionMatch) {
950
+ currentRemote = sectionMatch[1];
951
+ emittedUrl = false;
952
+ continue;
953
+ }
954
+ if (line.startsWith("[")) {
955
+ currentRemote = null;
956
+ emittedUrl = false;
957
+ continue;
958
+ }
959
+ if (currentRemote && !emittedUrl) {
960
+ const urlMatch = /^[ \t]+url[ \t]*=[ \t]*([^\r\n]+)/.exec(line);
961
+ if (urlMatch) {
962
+ lines.push(`${currentRemote} ${urlMatch[1].trim()} (fetch)`);
963
+ emittedUrl = true;
816
964
  }
817
- return { org: match[1], project };
818
965
  }
819
966
  }
820
- return null;
967
+ return lines.join("\n");
821
968
  }
822
969
  function detectAzdoContext() {
823
- let remoteUrl;
970
+ let configContent;
824
971
  try {
825
- remoteUrl = execSync("git remote get-url origin", { encoding: "utf-8" }).trim();
972
+ configContent = readGitConfigContent();
826
973
  } catch {
827
974
  throw new Error("Not in a git repository. Provide --org and --project explicitly.");
828
975
  }
829
- const context = parseAzdoRemote(remoteUrl);
830
- if (!context || !context.org && !context.project) {
831
- throw new Error('Git remote "origin" is not an Azure DevOps URL. Provide --org and --project explicitly.');
976
+ const remoteLines = gitConfigToRemoteLines(configContent);
977
+ const candidates = parseAllAzdoRemotes(remoteLines);
978
+ const selected = selectRemote(candidates);
979
+ if (selected.hasEmbeddedSecret) {
980
+ noticeCredentialBearingRemote(selected.remoteName);
832
981
  }
833
- return context;
982
+ return { org: selected.org, project: selected.project };
834
983
  }
835
984
  function parseRepoName(url) {
836
985
  for (const pattern of patterns) {
837
986
  const match = pattern.exec(url);
838
987
  if (match) {
988
+ if (httpsEmbeddedSecret.test(url)) {
989
+ noticeCredentialBearingRemote();
990
+ }
839
991
  return match[3];
840
992
  }
841
993
  }
@@ -844,7 +996,7 @@ function parseRepoName(url) {
844
996
  function detectRepoName() {
845
997
  let remoteUrl;
846
998
  try {
847
- remoteUrl = execSync("git remote get-url origin", { encoding: "utf-8" }).trim();
999
+ remoteUrl = execFileSync(GIT_BINARY, ["remote", "get-url", "origin"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
848
1000
  } catch {
849
1001
  throw new Error('Not in a git repository. Check that git remote "origin" exists and try again.');
850
1002
  }
@@ -855,7 +1007,7 @@ function detectRepoName() {
855
1007
  return repo;
856
1008
  }
857
1009
  function getCurrentBranch() {
858
- const branch = execSync("git rev-parse --abbrev-ref HEAD", { encoding: "utf-8" }).trim();
1010
+ const branch = execFileSync(GIT_BINARY, ["rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
859
1011
  if (branch === "HEAD") {
860
1012
  throw new Error("Not on a named branch. Check out a named branch and try again.");
861
1013
  }
@@ -891,7 +1043,7 @@ function formatResolutionError() {
891
1043
  return [
892
1044
  "Could not resolve an Azure DevOps organization. Options (in priority order):",
893
1045
  " 1. Pass --org <name> on the command line.",
894
- " 2. Run this command from a git repo whose origin remote is an Azure DevOps URL.",
1046
+ " 2. Run this command from a git repo that has an Azure DevOps remote.",
895
1047
  " 3. Run `azdo config set org <name>` once to set a persistent default."
896
1048
  ].join("\n");
897
1049
  }
@@ -904,9 +1056,9 @@ function resolveContext(options) {
904
1056
  gitContext = detectAzdoContext();
905
1057
  } catch {
906
1058
  }
907
- const config = loadConfig();
908
1059
  const org = resolvedOrg?.org;
909
- const project = options.project || (gitContext?.project && gitContext.project.length > 0 ? gitContext.project : void 0) || config.project;
1060
+ const scopedCfg = resolveScopedConfig(org);
1061
+ const project = options.project || (gitContext?.project && gitContext.project.length > 0 ? gitContext.project : void 0) || scopedCfg.project;
910
1062
  if (org && project) {
911
1063
  return { org, project };
912
1064
  }
@@ -1058,49 +1210,216 @@ function handleCommandError(err, id, context, scope = "write", exit = true) {
1058
1210
  }
1059
1211
  }
1060
1212
 
1061
- // src/commands/get-item.ts
1062
- function parseRequestedFields(raw) {
1063
- if (raw === void 0) return void 0;
1064
- const source = Array.isArray(raw) ? raw : [raw];
1065
- const tokens = source.flatMap((entry) => entry.split(/[,\s]+/)).map((field) => field.trim()).filter((field) => field.length > 0);
1066
- if (tokens.length === 0) return void 0;
1067
- return Array.from(new Set(tokens));
1213
+ // src/services/image-download.ts
1214
+ import { Jimp } from "jimp";
1215
+ import { writeFile } from "fs/promises";
1216
+ import { existsSync as existsSync2 } from "fs";
1217
+ import { tmpdir } from "os";
1218
+ import { join as join2 } from "path";
1219
+ var ATTACHMENT_GUID_RE = /_apis\/wit\/attachments\/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})/;
1220
+ function isAzureDevOpsAttachmentHost(hostname) {
1221
+ const host = hostname.toLowerCase();
1222
+ return host === "dev.azure.com" || host.endsWith(".dev.azure.com") || host.endsWith(".visualstudio.com");
1068
1223
  }
1069
- function stripHtml(html) {
1070
- let text = html;
1071
- text = text.replaceAll(/<h[1-6][^>]*>(.*?)<\/h[1-6]>/gi, "\n--- $1 ---\n");
1072
- text = text.replaceAll(/<br\s*\/?>/gi, "\n");
1073
- text = text.replaceAll(/<\/?(p|div)>/gi, "\n");
1074
- text = text.replaceAll(/<li>/gi, "\n");
1075
- text = removeHtmlTags(text);
1076
- text = text.replaceAll("&amp;", "&");
1077
- text = text.replaceAll("&lt;", "<");
1078
- text = text.replaceAll("&gt;", ">");
1079
- text = text.replaceAll("&quot;", '"');
1080
- text = text.replaceAll("&#39;", "'");
1081
- text = text.replaceAll("&nbsp;", " ");
1082
- text = text.replaceAll(/\n{3,}/g, "\n\n");
1083
- return text.trim();
1224
+ function decodeHtmlEntities(value) {
1225
+ return value.replaceAll("&quot;", '"').replaceAll("&#39;", "'").replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&amp;", "&");
1084
1226
  }
1085
- function removeHtmlTags(value) {
1086
- let result = "";
1087
- let insideTag = false;
1088
- for (const char of value) {
1089
- if (char === "<") {
1090
- insideTag = true;
1091
- continue;
1092
- }
1093
- if (char === ">" && insideTag) {
1094
- insideTag = false;
1095
- continue;
1096
- }
1097
- if (!insideTag) {
1098
- result += char;
1099
- }
1227
+ function parseAttachmentReference(rawUrl, sourceField) {
1228
+ const url = decodeHtmlEntities(rawUrl.trim());
1229
+ let parsed;
1230
+ try {
1231
+ parsed = new URL(url);
1232
+ } catch {
1233
+ return null;
1100
1234
  }
1101
- return result;
1235
+ if (parsed.protocol !== "https:" || !isAzureDevOpsAttachmentHost(parsed.hostname)) {
1236
+ return null;
1237
+ }
1238
+ const match = ATTACHMENT_GUID_RE.exec(parsed.pathname);
1239
+ if (!match) return null;
1240
+ const guid = match[1].toLowerCase();
1241
+ let suggestedExtension = ".png";
1242
+ const fileName = parsed.searchParams.get("fileName");
1243
+ if (fileName?.includes(".")) {
1244
+ suggestedExtension = fileName.slice(fileName.lastIndexOf(".")).toLowerCase();
1245
+ }
1246
+ return { url, sourceField, guid, suggestedExtension };
1102
1247
  }
1103
- function convertRichText(html, markdown) {
1248
+ function extractImageReferences(content, sourceField) {
1249
+ if (!content) return [];
1250
+ const references = [];
1251
+ const seen = /* @__PURE__ */ new Set();
1252
+ const add = (rawUrl) => {
1253
+ const reference = parseAttachmentReference(rawUrl, sourceField);
1254
+ if (reference && !seen.has(reference.guid)) {
1255
+ seen.add(reference.guid);
1256
+ references.push(reference);
1257
+ }
1258
+ };
1259
+ const imgRegex = /<img\b[^>]*?\ssrc\s*=\s*["']([^"']+)["']/gi;
1260
+ let match;
1261
+ while ((match = imgRegex.exec(content)) !== null) {
1262
+ add(match[1]);
1263
+ }
1264
+ const markdownRegex = /!\[[^\]]*\]\(\s*([^)\s]+)/g;
1265
+ while ((match = markdownRegex.exec(content)) !== null) {
1266
+ add(match[1]);
1267
+ }
1268
+ return references;
1269
+ }
1270
+ function addImageDownloadOptions(command) {
1271
+ return command.option("--download-images", "download images embedded in rich-text fields to local files").option("--resize-images <pixels>", "max image width in px; downloads and resizes embedded images to PNG (implies --download-images)").option("--images-path <dir>", "destination directory for downloaded images (default: system temp dir)");
1272
+ }
1273
+ function resolveImageDownloadOptionsOrExit(flags) {
1274
+ try {
1275
+ return resolveImageDownloadOptions(flags);
1276
+ } catch (err) {
1277
+ process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
1278
+ `);
1279
+ process.exit(1);
1280
+ }
1281
+ }
1282
+ function resolveImageDownloadOptions(flags) {
1283
+ const wantsResize = flags.resizeImages !== void 0;
1284
+ const enabled = Boolean(flags.downloadImages) || wantsResize;
1285
+ let maxWidth;
1286
+ if (wantsResize) {
1287
+ const parsed = Number(flags.resizeImages);
1288
+ if (!Number.isInteger(parsed) || parsed <= 0) {
1289
+ throw new Error(
1290
+ `Invalid --resize-images value "${flags.resizeImages}": must be a positive integer (max width in pixels).`
1291
+ );
1292
+ }
1293
+ maxWidth = parsed;
1294
+ }
1295
+ const outputDir = flags.imagesPath ?? tmpdir();
1296
+ if (flags.imagesPath !== void 0 && !existsSync2(outputDir)) {
1297
+ throw new Error(`Images path "${outputDir}" does not exist.`);
1298
+ }
1299
+ return { enabled, maxWidth, outputDir };
1300
+ }
1301
+ function buildImageFileName(workItemId, index, reference, resizing) {
1302
+ const ext = resizing ? ".png" : reference.suggestedExtension;
1303
+ return `wi-${workItemId}-${index}${ext}`;
1304
+ }
1305
+ async function processImageBytes(bytes, maxWidth) {
1306
+ if (maxWidth === void 0) {
1307
+ return { buffer: Buffer.from(bytes), resized: false, format: "original" };
1308
+ }
1309
+ const image = await Jimp.read(Buffer.from(bytes));
1310
+ let resized = false;
1311
+ if (image.bitmap.width > maxWidth) {
1312
+ image.resize({ w: maxWidth });
1313
+ resized = true;
1314
+ }
1315
+ const buffer = await image.getBuffer("image/png");
1316
+ return { buffer, resized, format: "png" };
1317
+ }
1318
+ async function downloadImagesFromFields(fields, args, credential) {
1319
+ const { workItemId, options } = args;
1320
+ const resizing = options.maxWidth !== void 0;
1321
+ const seen = /* @__PURE__ */ new Set();
1322
+ const references = [];
1323
+ for (const field of fields) {
1324
+ for (const reference of extractImageReferences(field.content, field.field)) {
1325
+ if (!seen.has(reference.guid)) {
1326
+ seen.add(reference.guid);
1327
+ references.push(reference);
1328
+ }
1329
+ }
1330
+ }
1331
+ const results = [];
1332
+ let index = 0;
1333
+ for (const reference of references) {
1334
+ index += 1;
1335
+ try {
1336
+ const bytes = await downloadAttachment(reference.url, credential);
1337
+ const processed = await processImageBytes(bytes, options.maxWidth);
1338
+ const fileName = buildImageFileName(workItemId, index, reference, resizing);
1339
+ const outputPath = join2(options.outputDir, fileName);
1340
+ await writeFile(outputPath, processed.buffer);
1341
+ results.push({
1342
+ reference,
1343
+ path: outputPath,
1344
+ resized: processed.resized,
1345
+ format: resizing ? "png" : reference.suggestedExtension.replace(/^\./, "")
1346
+ });
1347
+ } catch (err) {
1348
+ results.push({
1349
+ reference,
1350
+ resized: false,
1351
+ format: reference.suggestedExtension.replace(/^\./, ""),
1352
+ error: err instanceof Error ? err.message : String(err)
1353
+ });
1354
+ }
1355
+ }
1356
+ return results;
1357
+ }
1358
+ async function runImageDownload(fields, args, credential) {
1359
+ const results = await downloadImagesFromFields(fields, args, credential);
1360
+ process.stdout.write(formatImageSummary(results) + "\n");
1361
+ for (const result of results) {
1362
+ if (result.error) {
1363
+ process.stderr.write(`Failed to download image ${result.reference.url}: ${result.error}
1364
+ `);
1365
+ }
1366
+ }
1367
+ }
1368
+ function formatImageSummary(results) {
1369
+ if (results.length === 0) {
1370
+ return "Images: no images found in rich-text fields";
1371
+ }
1372
+ const saved = results.filter((r) => r.path);
1373
+ const lines = [`Images: ${saved.length} downloaded`];
1374
+ for (const result of saved) {
1375
+ lines.push(` ${result.path}`);
1376
+ }
1377
+ return lines.join("\n");
1378
+ }
1379
+
1380
+ // src/commands/get-item.ts
1381
+ function parseRequestedFields(raw) {
1382
+ if (raw === void 0) return void 0;
1383
+ const source = Array.isArray(raw) ? raw : [raw];
1384
+ const tokens = source.flatMap((entry) => entry.split(/[,\s]+/)).map((field) => field.trim()).filter((field) => field.length > 0);
1385
+ if (tokens.length === 0) return void 0;
1386
+ return Array.from(new Set(tokens));
1387
+ }
1388
+ function stripHtml(html) {
1389
+ let text = html;
1390
+ text = text.replaceAll(/<h[1-6][^>]*>(.*?)<\/h[1-6]>/gi, "\n--- $1 ---\n");
1391
+ text = text.replaceAll(/<br\s*\/?>/gi, "\n");
1392
+ text = text.replaceAll(/<\/?(p|div)>/gi, "\n");
1393
+ text = text.replaceAll(/<li>/gi, "\n");
1394
+ text = removeHtmlTags(text);
1395
+ text = text.replaceAll("&lt;", "<");
1396
+ text = text.replaceAll("&gt;", ">");
1397
+ text = text.replaceAll("&quot;", '"');
1398
+ text = text.replaceAll("&#39;", "'");
1399
+ text = text.replaceAll("&nbsp;", " ");
1400
+ text = text.replaceAll("&amp;", "&");
1401
+ text = text.replaceAll(/\n{3,}/g, "\n\n");
1402
+ return text.trim();
1403
+ }
1404
+ function removeHtmlTags(value) {
1405
+ let result = "";
1406
+ let insideTag = false;
1407
+ for (const char of value) {
1408
+ if (char === "<") {
1409
+ insideTag = true;
1410
+ continue;
1411
+ }
1412
+ if (char === ">" && insideTag) {
1413
+ insideTag = false;
1414
+ continue;
1415
+ }
1416
+ if (!insideTag) {
1417
+ result += char;
1418
+ }
1419
+ }
1420
+ return result;
1421
+ }
1422
+ function convertRichText(html, markdown) {
1104
1423
  if (!html) return "";
1105
1424
  return markdown ? toMarkdown(html) : stripHtml(html);
1106
1425
  }
@@ -1181,19 +1500,32 @@ function formatWorkItem(workItem, short, markdown = false) {
1181
1500
  }
1182
1501
  function createGetItemCommand() {
1183
1502
  const command = new Command("get-item");
1184
- command.description("Retrieve an Azure DevOps work item by ID").argument("<id>", "work item ID").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--short", "show abbreviated output").option("--fields <fields>", "comma-separated additional field reference names").option("--markdown", "convert rich text fields to markdown").action(
1503
+ command.description("Retrieve an Azure DevOps work item by ID").argument("<id>", "work item ID").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--short", "show abbreviated output").option("--fields <fields>", "comma-separated additional field reference names").option("--markdown", "convert rich text fields to markdown");
1504
+ addImageDownloadOptions(command);
1505
+ command.action(
1185
1506
  async (idStr, options) => {
1186
1507
  const id = parseWorkItemId(idStr);
1187
1508
  validateOrgProjectPair(options);
1509
+ const imageOptions = resolveImageDownloadOptionsOrExit(options);
1188
1510
  let context;
1189
1511
  try {
1190
1512
  context = resolveContext(options);
1191
- const credential = await requirePat(context.org);
1192
- const fieldsList = options.fields === void 0 ? parseRequestedFields(loadConfig().fields) : parseRequestedFields(options.fields);
1193
- const workItem = await getWorkItem(context, id, credential.pat, fieldsList);
1194
- const markdownEnabled = options.markdown ?? loadConfig().markdown ?? false;
1513
+ const credential = await requireAuthCredential(context.org);
1514
+ const scopedCfg = resolveScopedConfig(context.org);
1515
+ const fieldsList = options.fields === void 0 ? parseRequestedFields(scopedCfg.fields) : parseRequestedFields(options.fields);
1516
+ const workItem = await getWorkItem(context, id, credential, fieldsList);
1517
+ const markdownEnabled = options.markdown ?? scopedCfg.markdown ?? false;
1195
1518
  const output = formatWorkItem(workItem, options.short ?? false, markdownEnabled);
1196
1519
  process.stdout.write(output + "\n");
1520
+ if (imageOptions.enabled) {
1521
+ const fields = [{ content: workItem.description ?? "", field: "Description" }];
1522
+ if (workItem.extraFields) {
1523
+ for (const [name, value] of Object.entries(workItem.extraFields)) {
1524
+ fields.push({ content: value, field: name });
1525
+ }
1526
+ }
1527
+ await runImageDownload(fields, { workItemId: id, options: imageOptions }, credential);
1528
+ }
1197
1529
  } catch (err) {
1198
1530
  handleCommandError(err, id, context, "read", false);
1199
1531
  }
@@ -1229,63 +1561,6 @@ function createClearPatCommand() {
1229
1561
 
1230
1562
  // src/commands/auth.ts
1231
1563
  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
1564
  async function readStdinToString() {
1290
1565
  const chunks = [];
1291
1566
  for await (const chunk of process.stdin) {
@@ -1293,9 +1568,9 @@ async function readStdinToString() {
1293
1568
  }
1294
1569
  return Buffer.concat(chunks).toString("utf8");
1295
1570
  }
1296
- async function confirmOverwrite(org) {
1571
+ async function promptYesNo(prompt) {
1297
1572
  if (!process.stdin.isTTY) return true;
1298
- process.stderr.write(`A PAT is already stored for org ${org}. Overwrite? [y/N] `);
1573
+ process.stderr.write(prompt);
1299
1574
  return await new Promise((resolve2) => {
1300
1575
  process.stdin.setEncoding("utf8");
1301
1576
  let answered = false;
@@ -1311,7 +1586,23 @@ async function confirmOverwrite(org) {
1311
1586
  process.stdin.on("data", handler);
1312
1587
  });
1313
1588
  }
1314
- async function handleAuthRoot(options) {
1589
+ async function confirmOverwrite(org) {
1590
+ return promptYesNo(`A PAT is already stored for org ${org}. Overwrite? [y/N] `);
1591
+ }
1592
+ async function confirmOverwriteCredential(org, existingKind) {
1593
+ const label = existingKind === "oauth" ? "OAuth credential" : "PAT";
1594
+ return promptYesNo(`A ${label} is already stored for org ${org}. The new login will replace it. Continue? [y/N] `);
1595
+ }
1596
+ function rejectMutuallyExclusive(opts) {
1597
+ if (opts.usePat && opts.deviceCode) {
1598
+ return "--use-pat and --device-code are mutually exclusive (PAT has no device-code flow).";
1599
+ }
1600
+ if (opts.usePat && (opts.clientId || opts.tenantId || opts.scopes)) {
1601
+ return "--use-pat cannot be combined with OAuth-only flags (--client-id / --tenant-id / --scopes).";
1602
+ }
1603
+ return null;
1604
+ }
1605
+ async function handlePatLogin(options) {
1315
1606
  const resolved = resolveOrg({ org: options.org });
1316
1607
  if (!resolved) {
1317
1608
  process.stderr.write(`${formatResolutionError()}
@@ -1377,7 +1668,145 @@ async function handleAuthRoot(options) {
1377
1668
  process.stdout.write(`PAT stored for org ${org} in ${probeBackend()}.
1378
1669
  `);
1379
1670
  }
1671
+ async function ensureOverwriteConfirmed(org) {
1672
+ try {
1673
+ const existing = await getStoredCredential(org);
1674
+ if (existing === null || !process.stdin.isTTY) return "ok";
1675
+ const ok = await confirmOverwriteCredential(org, existing.kind);
1676
+ return ok ? "ok" : "aborted";
1677
+ } catch (err) {
1678
+ if (err instanceof CredentialStoreUnavailableError) {
1679
+ process.stderr.write(`${err.message}
1680
+ `);
1681
+ return "unavailable";
1682
+ }
1683
+ throw err;
1684
+ }
1685
+ }
1686
+ function buildOAuthLoginOptions(options) {
1687
+ return {
1688
+ flow: options.deviceCode ? "device-code" : "auto",
1689
+ clientIdOverride: options.clientId,
1690
+ tenantIdOverride: options.tenantId,
1691
+ scopesOverride: options.scopes ? options.scopes.split(/\s+/).filter(Boolean) : void 0
1692
+ };
1693
+ }
1694
+ function reportOAuthFailure(err) {
1695
+ const reason = typeof err === "object" && err !== null && "reason" in err ? err.reason : null;
1696
+ const msg = err.message;
1697
+ process.stderr.write(reason ? `OAuth login failed (${reason}): ${msg}
1698
+ ` : `OAuth login failed: ${msg}
1699
+ `);
1700
+ const noDisplay = process.platform === "linux" && (!process.env.DISPLAY || process.env.DISPLAY.length === 0);
1701
+ if (noDisplay) {
1702
+ process.stderr.write("Tip: this host has no DISPLAY; pass --device-code to use the headless flow.\n");
1703
+ } else if (reason === "port-conflict") {
1704
+ process.stderr.write("Tip: another process is using the loopback callback port. Try again or pass --device-code.\n");
1705
+ }
1706
+ }
1707
+ async function handleOAuthLogin(options) {
1708
+ const resolved = resolveOrg({ org: options.org });
1709
+ if (!resolved) {
1710
+ process.stderr.write(`${formatResolutionError()}
1711
+ `);
1712
+ process.exitCode = 3;
1713
+ return;
1714
+ }
1715
+ const org = resolved.org;
1716
+ const confirm = await ensureOverwriteConfirmed(org);
1717
+ if (confirm === "aborted") {
1718
+ process.stderr.write("Aborted. Existing credential preserved.\n");
1719
+ process.exitCode = 1;
1720
+ return;
1721
+ }
1722
+ if (confirm === "unavailable") {
1723
+ process.exitCode = 4;
1724
+ return;
1725
+ }
1726
+ try {
1727
+ const result = await loginWithOAuth(org, buildOAuthLoginOptions(options));
1728
+ process.stdout.write(
1729
+ `Logged in to ${org} via OAuth (${result.flowUsed}). Account: ${result.accountId}; expires ${new Date(result.expiresAt * 1e3).toISOString()}.
1730
+ `
1731
+ );
1732
+ } catch (err) {
1733
+ reportOAuthFailure(err);
1734
+ process.exitCode = 1;
1735
+ }
1736
+ }
1737
+ async function handleAuthRoot(options) {
1738
+ const conflict = rejectMutuallyExclusive(options);
1739
+ if (conflict) {
1740
+ process.stderr.write(`${conflict}
1741
+ `);
1742
+ process.exitCode = 2;
1743
+ return;
1744
+ }
1745
+ await handlePatLogin(options);
1746
+ }
1747
+ async function handleLoginSubcommand(options) {
1748
+ const conflict = rejectMutuallyExclusive(options);
1749
+ if (conflict) {
1750
+ process.stderr.write(`${conflict}
1751
+ `);
1752
+ process.exitCode = 2;
1753
+ return;
1754
+ }
1755
+ if (options.fromStdin || options.usePat) {
1756
+ await handlePatLogin(options);
1757
+ return;
1758
+ }
1759
+ await handleOAuthLogin(options);
1760
+ }
1761
+ async function handleStatusJson() {
1762
+ try {
1763
+ const report = await status();
1764
+ process.stdout.write(`${JSON.stringify(report)}
1765
+ `);
1766
+ } catch (err) {
1767
+ if (err instanceof CredentialStoreUnavailableError) {
1768
+ process.stderr.write(`${err.message}
1769
+ `);
1770
+ process.exitCode = 4;
1771
+ return;
1772
+ }
1773
+ throw err;
1774
+ }
1775
+ }
1380
1776
  async function handleStatus(options, org) {
1777
+ if (options.json) {
1778
+ let backend2;
1779
+ let value2;
1780
+ try {
1781
+ backend2 = probeBackend();
1782
+ value2 = await getPat(org);
1783
+ } catch (err) {
1784
+ if (err instanceof CredentialStoreUnavailableError) {
1785
+ process.stderr.write(`${err.message}
1786
+ `);
1787
+ process.exitCode = 4;
1788
+ return;
1789
+ }
1790
+ throw err;
1791
+ }
1792
+ const storedEvents2 = readAuditEvents().filter((ev) => ev.org === org && ev.event === "auth.store");
1793
+ const last2 = storedEvents2.at(-1);
1794
+ const updatedAt2 = last2?.ts ?? null;
1795
+ if (!value2) {
1796
+ process.stdout.write(
1797
+ `${JSON.stringify({ org, backend: backend2, stored: false, masked: null, updated_at: updatedAt2 })}
1798
+ `
1799
+ );
1800
+ process.exitCode = 1;
1801
+ return;
1802
+ }
1803
+ const masked2 = maskedDisplay(value2);
1804
+ process.stdout.write(
1805
+ `${JSON.stringify({ org, backend: backend2, stored: true, masked: masked2, updated_at: updatedAt2 })}
1806
+ `
1807
+ );
1808
+ return;
1809
+ }
1381
1810
  let backend;
1382
1811
  let value;
1383
1812
  try {
@@ -1393,39 +1822,25 @@ async function handleStatus(options, org) {
1393
1822
  throw err;
1394
1823
  }
1395
1824
  const storedEvents = readAuditEvents().filter((ev) => ev.org === org && ev.event === "auth.store");
1396
- const last = storedEvents[storedEvents.length - 1];
1825
+ const last = storedEvents.at(-1);
1397
1826
  const updatedAt = last?.ts ?? null;
1398
1827
  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}
1828
+ process.stdout.write(`Organization: ${org}
1406
1829
  Backend: ${backend}
1407
1830
  Stored: no
1408
1831
  `);
1409
- }
1410
1832
  process.exitCode = 1;
1411
1833
  return;
1412
1834
  }
1413
1835
  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}
1836
+ process.stdout.write(
1837
+ `Organization: ${org}
1422
1838
  Backend: ${backend}
1423
1839
  Stored: yes
1424
1840
  Identifier: ${masked}
1425
1841
  ` + (updatedAt ? `Last updated: ${updatedAt}
1426
1842
  ` : "")
1427
- );
1428
- }
1843
+ );
1429
1844
  }
1430
1845
  async function handleLogout(options, orgFromGlobal) {
1431
1846
  if (options.all && orgFromGlobal) {
@@ -1434,9 +1849,16 @@ async function handleLogout(options, orgFromGlobal) {
1434
1849
  return;
1435
1850
  }
1436
1851
  if (options.all) {
1437
- let orgs;
1438
1852
  try {
1439
- orgs = await listOrgsWithStoredPat();
1853
+ const result = await logout({ all: true });
1854
+ if (result.removed.length === 0) {
1855
+ process.stdout.write("No stored credentials to remove.\n");
1856
+ return;
1857
+ }
1858
+ for (const r of result.removed) {
1859
+ process.stdout.write(`Removed ${r.kind} credential for org ${r.org}.
1860
+ `);
1861
+ }
1440
1862
  } catch (err) {
1441
1863
  if (err instanceof CredentialStoreUnavailableError) {
1442
1864
  process.stderr.write(`${err.message}
@@ -1444,22 +1866,9 @@ async function handleLogout(options, orgFromGlobal) {
1444
1866
  process.exitCode = 4;
1445
1867
  return;
1446
1868
  }
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}
1869
+ process.stderr.write(`Failed to remove credentials: ${err.message}
1460
1870
  `);
1461
- process.exitCode = 1;
1462
- }
1871
+ process.exitCode = 1;
1463
1872
  }
1464
1873
  return;
1465
1874
  }
@@ -1473,10 +1882,10 @@ async function handleLogout(options, orgFromGlobal) {
1473
1882
  try {
1474
1883
  const removed = await deletePat(resolved.org);
1475
1884
  if (removed) {
1476
- process.stdout.write(`PAT removed for org ${resolved.org}.
1885
+ process.stdout.write(`Credential removed for org ${resolved.org}.
1477
1886
  `);
1478
1887
  } else {
1479
- process.stdout.write(`No stored PAT found for org ${resolved.org}.
1888
+ process.stdout.write(`No stored credential for org ${resolved.org}.
1480
1889
  `);
1481
1890
  }
1482
1891
  } catch (err) {
@@ -1491,14 +1900,70 @@ async function handleLogout(options, orgFromGlobal) {
1491
1900
  }
1492
1901
  function createAuthCommand() {
1493
1902
  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) => {
1903
+ command.description(
1904
+ "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."
1905
+ );
1906
+ 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)");
1907
+ command.addHelpText(
1908
+ "after",
1909
+ `
1910
+ Default flow (OAuth, browser-based):
1911
+ azdo auth login --org <name>
1912
+ \u2192 opens the default browser for OAuth (Microsoft Entra v2 + PKCE).
1913
+
1914
+ Headless / no-browser:
1915
+ azdo auth login --org <name> --device-code
1916
+
1917
+ PAT path (legacy, opt-in):
1918
+ azdo auth login --org <name> --use-pat
1919
+ azdo auth --org <name> # back-compat alias of the above
1920
+
1921
+ OAuth scope set \u2014 shipped first-party client (default install):
1922
+ ${firstPartyShippedScopes().join("\n ")}
1923
+ (uses ${AZDO_RESOURCE_ID}/.default \u2014 per-scope consent is unavailable
1924
+ against a client we do not own; .default grants the VS client's
1925
+ pre-authorized AzDO permissions in one step.)
1926
+
1927
+ OAuth scope set \u2014 self-registered apps (--client-id / AZDO_OAUTH_CLIENT_ID):
1928
+ ${defaultScopes().join("\n ")}
1929
+ (FR-016, mirrors the PAT scope table \u2014 see docs/oauth-app-registration.md)
1930
+
1931
+ For self-registered OAuth apps (locked-down tenants), see docs/oauth-app-registration.md
1932
+ \u2014 that same guide is the maintainer reference for the project's shared client id.
1933
+
1934
+ Note: stored credentials may coexist as 'pat' or 'oauth' across orgs (FR-007).
1935
+ Note: \`azdo auth\` (no subcommand) preserves the legacy PAT-prompt entry point;
1936
+ \`azdo auth login\` is the spec-canonical name and defaults to OAuth.`
1937
+ ).action(async (options) => {
1497
1938
  await handleAuthRoot(options);
1498
1939
  });
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);
1940
+ 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)");
1941
+ loginCmd.action(async () => {
1942
+ const merged = loginCmd.optsWithGlobals();
1943
+ await handleLoginSubcommand(merged);
1944
+ });
1945
+ const statusCmd = command.command("status").description("Report stored credentials (kind, org, account/expiry, backend) \u2014 never the token").option("--json", "emit JSON", false);
1500
1946
  statusCmd.action(async (options) => {
1501
1947
  const globals = statusCmd.optsWithGlobals();
1948
+ if (!globals.org) {
1949
+ if (options.json) {
1950
+ await handleStatusJson();
1951
+ return;
1952
+ }
1953
+ const report = await status();
1954
+ if (report.orgs.length === 0) {
1955
+ process.stdout.write("No stored credentials.\n");
1956
+ return;
1957
+ }
1958
+ for (const e of report.orgs) {
1959
+ const expiry = e.expiresAt ? new Date(e.expiresAt * 1e3).toISOString() : "n/a";
1960
+ process.stdout.write(
1961
+ `${e.org} ${e.kind} ${e.accountId ?? ""} ${expiry}
1962
+ `
1963
+ );
1964
+ }
1965
+ return;
1966
+ }
1502
1967
  const resolved = resolveOrg({ org: globals.org });
1503
1968
  if (!resolved) {
1504
1969
  process.stderr.write(`${formatResolutionError()}
@@ -1508,7 +1973,11 @@ function createAuthCommand() {
1508
1973
  }
1509
1974
  await handleStatus(options, resolved.org);
1510
1975
  });
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);
1976
+ statusCmd.addHelpText(
1977
+ "after",
1978
+ "\nStored credentials may be of kind `pat` or `oauth` and may coexist across orgs (FR-007).\n"
1979
+ );
1980
+ 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
1981
  logoutCmd.action(async (options) => {
1513
1982
  const globals = logoutCmd.optsWithGlobals();
1514
1983
  await handleLogout(options, globals.org);
@@ -1525,18 +1994,47 @@ function formatConfigValue(value, unsetFallback = "") {
1525
1994
  }
1526
1995
  return Array.isArray(value) ? value.join(",") : value;
1527
1996
  }
1528
- function writeConfigList(cfg) {
1997
+ function buildConfigListEntries(cfg) {
1998
+ const entries = SETTINGS.map((s) => ({
1999
+ scope: "default",
2000
+ key: s.key,
2001
+ value: cfg[s.key]
2002
+ }));
2003
+ for (const [orgName, scope] of Object.entries(cfg.organizations ?? {})) {
2004
+ for (const [k, v] of Object.entries(scope)) {
2005
+ entries.push({ scope: orgName, key: k, value: v });
2006
+ }
2007
+ }
2008
+ return entries;
2009
+ }
2010
+ function writeConfigList(cfg) {
1529
2011
  const keyWidth = 10;
1530
2012
  const valueWidth = 30;
2013
+ const scopeWidth = 12;
2014
+ process.stdout.write(
2015
+ `${"scope".padEnd(scopeWidth)}${"key".padEnd(keyWidth)}${"value".padEnd(valueWidth)}description
2016
+ `
2017
+ );
1531
2018
  for (const setting of SETTINGS) {
1532
2019
  const raw = cfg[setting.key];
1533
2020
  const value = formatConfigValue(raw, "(not set)");
1534
2021
  const marker = raw === void 0 && setting.required ? " *" : "";
1535
2022
  process.stdout.write(
1536
- `${setting.key.padEnd(keyWidth)}${String(value).padEnd(valueWidth)}${setting.description}${marker}
2023
+ `${"default".padEnd(scopeWidth)}${setting.key.padEnd(keyWidth)}${String(value).padEnd(valueWidth)}${setting.description}${marker}
1537
2024
  `
1538
2025
  );
1539
2026
  }
2027
+ for (const [orgName, scope] of Object.entries(cfg.organizations ?? {})) {
2028
+ const orgScope = scope;
2029
+ const scopedSettings = Object.entries(orgScope);
2030
+ for (const [k, v] of scopedSettings) {
2031
+ const value = formatConfigValue(v, "(not set)");
2032
+ process.stdout.write(
2033
+ `${orgName.padEnd(scopeWidth)}${k.padEnd(keyWidth)}${String(value).padEnd(valueWidth)}
2034
+ `
2035
+ );
2036
+ }
2037
+ }
1540
2038
  const hasUnset = SETTINGS.some((s) => s.required && cfg[s.key] === void 0);
1541
2039
  if (hasUnset) {
1542
2040
  process.stdout.write(
@@ -1580,17 +2078,22 @@ function createConfigCommand() {
1580
2078
  const config = new Command4("config");
1581
2079
  config.description("Manage CLI settings");
1582
2080
  const set = new Command4("set");
1583
- set.description("Set a configuration value").argument("<key>", "setting key (org, project, fields)").argument("<value>", "setting value").option("--json", "output in JSON format").action((key, value, options) => {
2081
+ set.description("Set a configuration value").argument("<key>", "setting key (org, project, fields, markdown)").argument("<value>", "setting value").option("--org <org>", "set value in an org-scoped configuration").option("--json", "output in JSON format").action((key, value, options) => {
1584
2082
  try {
1585
- setConfigValue(key, value);
2083
+ if (options.org) {
2084
+ setOrgScopedValue(options.org, key, value);
2085
+ } else {
2086
+ setConfigValue(key, value);
2087
+ }
1586
2088
  if (options.json) {
1587
- const output = { key, value };
2089
+ const output = { key, value, scope: options.org ?? "default" };
1588
2090
  if (key === "fields") {
1589
2091
  output.value = value.split(",").map((s) => s.trim());
1590
2092
  }
1591
2093
  process.stdout.write(JSON.stringify(output) + "\n");
1592
2094
  } else {
1593
- process.stdout.write(`Set "${key}" to "${value}"
2095
+ const scopeTag = options.org ? ` (org: ${options.org})` : "";
2096
+ process.stdout.write(`Set "${key}" to "${value}"${scopeTag}
1594
2097
  `);
1595
2098
  }
1596
2099
  } catch (err) {
@@ -1601,12 +2104,12 @@ function createConfigCommand() {
1601
2104
  }
1602
2105
  });
1603
2106
  const get = new Command4("get");
1604
- get.description("Get a configuration value").argument("<key>", "setting key (org, project, fields)").option("--json", "output in JSON format").action((key, options) => {
2107
+ get.description("Get a configuration value").argument("<key>", "setting key (org, project, fields, markdown)").option("--org <org>", "read from an org-scoped configuration").option("--json", "output in JSON format").action((key, options) => {
1605
2108
  try {
1606
- const value = getConfigValue(key);
2109
+ const value = options.org ? getOrgScopedValue(options.org, key) : getConfigValue(key);
1607
2110
  if (options.json) {
1608
2111
  process.stdout.write(
1609
- JSON.stringify({ key, value: value ?? null }) + "\n"
2112
+ JSON.stringify({ key, value: value ?? null, scope: options.org ?? "default" }) + "\n"
1610
2113
  );
1611
2114
  } else if (value === void 0) {
1612
2115
  process.stdout.write(`Setting "${key}" is not configured.
@@ -1614,7 +2117,7 @@ function createConfigCommand() {
1614
2117
  } else if (Array.isArray(value)) {
1615
2118
  process.stdout.write(value.join(",") + "\n");
1616
2119
  } else {
1617
- process.stdout.write(value + "\n");
2120
+ process.stdout.write(String(value) + "\n");
1618
2121
  }
1619
2122
  } catch (err) {
1620
2123
  const message = err instanceof Error ? err.message : String(err);
@@ -1627,19 +2130,25 @@ function createConfigCommand() {
1627
2130
  list.description("List all configuration values").option("--json", "output in JSON format").action((options) => {
1628
2131
  const cfg = loadConfig();
1629
2132
  if (options.json) {
1630
- process.stdout.write(JSON.stringify(cfg) + "\n");
2133
+ const entries = buildConfigListEntries(cfg);
2134
+ process.stdout.write(JSON.stringify(entries) + "\n");
1631
2135
  return;
1632
2136
  }
1633
2137
  writeConfigList(cfg);
1634
2138
  });
1635
2139
  const unset = new Command4("unset");
1636
- unset.description("Remove a configuration value").argument("<key>", "setting key (org, project, fields)").option("--json", "output in JSON format").action((key, options) => {
2140
+ unset.description("Remove a configuration value").argument("<key>", "setting key (org, project, fields, markdown)").option("--org <org>", "remove from an org-scoped configuration").option("--json", "output in JSON format").action((key, options) => {
1637
2141
  try {
1638
- unsetConfigValue(key);
2142
+ if (options.org) {
2143
+ unsetOrgScopedValue(options.org, key);
2144
+ } else {
2145
+ unsetConfigValue(key);
2146
+ }
1639
2147
  if (options.json) {
1640
- process.stdout.write(JSON.stringify({ key, unset: true }) + "\n");
2148
+ process.stdout.write(JSON.stringify({ key, unset: true, scope: options.org ?? "default" }) + "\n");
1641
2149
  } else {
1642
- process.stdout.write(`Unset "${key}"
2150
+ const scopeTag = options.org ? ` (org: ${options.org})` : "";
2151
+ process.stdout.write(`Unset "${key}"${scopeTag}
1643
2152
  `);
1644
2153
  }
1645
2154
  } catch (err) {
@@ -1671,10 +2180,52 @@ function createConfigCommand() {
1671
2180
  rl.close();
1672
2181
  process.stderr.write("Configuration complete!\n");
1673
2182
  });
2183
+ const orgCopy = new Command4("org-copy");
2184
+ orgCopy.description('Copy settings from one scope to another (use "default" as source to copy top-level settings)').argument("<from>", 'source scope name or "default"').argument("<to>", "destination org name").option("--force", "overwrite existing values on collision").action((from, to, options) => {
2185
+ try {
2186
+ copyOrgScope(from, to, options.force ?? false);
2187
+ process.stdout.write(`Copied scope "${from}" to "${to}"
2188
+ `);
2189
+ } catch (err) {
2190
+ const message = err instanceof Error ? err.message : String(err);
2191
+ process.stderr.write(`Error: ${message}
2192
+ `);
2193
+ process.exit(1);
2194
+ }
2195
+ });
2196
+ const orgMove = new Command4("org-move");
2197
+ orgMove.description("Move settings from one org scope to another").argument("<from>", "source org name").argument("<to>", "destination org name").option("--force", "overwrite existing values on collision").action((from, to, options) => {
2198
+ try {
2199
+ moveOrgScope(from, to, options.force ?? false);
2200
+ process.stdout.write(`Moved scope "${from}" to "${to}"
2201
+ `);
2202
+ } catch (err) {
2203
+ const message = err instanceof Error ? err.message : String(err);
2204
+ process.stderr.write(`Error: ${message}
2205
+ `);
2206
+ process.exit(1);
2207
+ }
2208
+ });
2209
+ const orgDelete = new Command4("org-delete");
2210
+ orgDelete.description("Delete an org-scoped configuration").argument("<name>", "org name").action((name) => {
2211
+ try {
2212
+ deleteOrgScope(name);
2213
+ process.stdout.write(`Deleted scope "${name}"
2214
+ `);
2215
+ } catch (err) {
2216
+ const message = err instanceof Error ? err.message : String(err);
2217
+ process.stderr.write(`Error: ${message}
2218
+ `);
2219
+ process.exit(1);
2220
+ }
2221
+ });
1674
2222
  config.addCommand(set);
1675
2223
  config.addCommand(get);
1676
2224
  config.addCommand(list);
1677
2225
  config.addCommand(unset);
2226
+ config.addCommand(orgCopy);
2227
+ config.addCommand(orgMove);
2228
+ config.addCommand(orgDelete);
1678
2229
  config.addCommand(wizard);
1679
2230
  return config;
1680
2231
  }
@@ -1690,11 +2241,11 @@ function createSetStateCommand() {
1690
2241
  let context;
1691
2242
  try {
1692
2243
  context = resolveContext(options);
1693
- const credential = await requirePat(context.org);
2244
+ const credential = await requireAuthCredential(context.org);
1694
2245
  const operations = [
1695
2246
  { op: "add", path: "/fields/System.State", value: state }
1696
2247
  ];
1697
- const result = await updateWorkItem(context, id, credential.pat, "System.State", operations);
2248
+ const result = await updateWorkItem(context, id, credential, "System.State", operations);
1698
2249
  if (options.json) {
1699
2250
  process.stdout.write(
1700
2251
  JSON.stringify({
@@ -1740,12 +2291,12 @@ function createAssignCommand() {
1740
2291
  let context;
1741
2292
  try {
1742
2293
  context = resolveContext(options);
1743
- const credential = await requirePat(context.org);
2294
+ const credential = await requireAuthCredential(context.org);
1744
2295
  const value = options.unassign ? "" : name;
1745
2296
  const operations = [
1746
2297
  { op: "add", path: "/fields/System.AssignedTo", value }
1747
2298
  ];
1748
- const result = await updateWorkItem(context, id, credential.pat, "System.AssignedTo", operations);
2299
+ const result = await updateWorkItem(context, id, credential, "System.AssignedTo", operations);
1749
2300
  if (options.json) {
1750
2301
  process.stdout.write(
1751
2302
  JSON.stringify({
@@ -1780,11 +2331,11 @@ function createSetFieldCommand() {
1780
2331
  let context;
1781
2332
  try {
1782
2333
  context = resolveContext(options);
1783
- const credential = await requirePat(context.org);
2334
+ const credential = await requireAuthCredential(context.org);
1784
2335
  const operations = [
1785
2336
  { op: "add", path: `/fields/${field}`, value }
1786
2337
  ];
1787
- const result = await updateWorkItem(context, id, credential.pat, field, operations);
2338
+ const result = await updateWorkItem(context, id, credential, field, operations);
1788
2339
  if (options.json) {
1789
2340
  process.stdout.write(
1790
2341
  JSON.stringify({
@@ -1811,20 +2362,30 @@ function createSetFieldCommand() {
1811
2362
  import { Command as Command8 } from "commander";
1812
2363
  function createGetMdFieldCommand() {
1813
2364
  const command = new Command8("get-md-field");
1814
- command.description("Get a work item field value, converting HTML to markdown").argument("<id>", "work item ID").argument("<field>", "field reference name (e.g., System.Description)").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").action(
2365
+ command.description("Get a work item field value, converting HTML to markdown").argument("<id>", "work item ID").argument("<field>", "field reference name (e.g., System.Description)").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project");
2366
+ addImageDownloadOptions(command);
2367
+ command.action(
1815
2368
  async (idStr, field, options) => {
1816
2369
  const id = parseWorkItemId(idStr);
1817
2370
  validateOrgProjectPair(options);
2371
+ const imageOptions = resolveImageDownloadOptionsOrExit(options);
1818
2372
  let context;
1819
2373
  try {
1820
2374
  context = resolveContext(options);
1821
- const credential = await requirePat(context.org);
1822
- const value = await getWorkItemFieldValue(context, id, credential.pat, field);
2375
+ const credential = await requireAuthCredential(context.org);
2376
+ const value = await getWorkItemFieldValue(context, id, credential, field);
1823
2377
  if (value === null) {
1824
2378
  process.stdout.write("\n");
1825
2379
  } else {
1826
2380
  process.stdout.write(toMarkdown(value) + "\n");
1827
2381
  }
2382
+ if (imageOptions.enabled) {
2383
+ await runImageDownload(
2384
+ [{ content: value ?? "", field }],
2385
+ { workItemId: id, options: imageOptions },
2386
+ credential
2387
+ );
2388
+ }
1828
2389
  } catch (err) {
1829
2390
  handleCommandError(err, id, context, "read");
1830
2391
  }
@@ -1834,7 +2395,7 @@ function createGetMdFieldCommand() {
1834
2395
  }
1835
2396
 
1836
2397
  // src/commands/set-md-field.ts
1837
- import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
2398
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
1838
2399
  import { Command as Command9 } from "commander";
1839
2400
  function fail(message) {
1840
2401
  process.stderr.write(`Error: ${message}
@@ -1854,7 +2415,7 @@ function resolveContent(inlineContent, options) {
1854
2415
  return null;
1855
2416
  }
1856
2417
  function readFileContent(filePath) {
1857
- if (!existsSync2(filePath)) {
2418
+ if (!existsSync3(filePath)) {
1858
2419
  fail(`File not found: ${filePath}`);
1859
2420
  }
1860
2421
  try {
@@ -1906,12 +2467,12 @@ function createSetMdFieldCommand() {
1906
2467
  let context;
1907
2468
  try {
1908
2469
  context = resolveContext(options);
1909
- const credential = await requirePat(context.org);
2470
+ const credential = await requireAuthCredential(context.org);
1910
2471
  const operations = [
1911
2472
  { op: "add", path: `/fields/${field}`, value: content },
1912
2473
  { op: "add", path: `/multilineFieldsFormat/${field}`, value: "Markdown" }
1913
2474
  ];
1914
- const result = await updateWorkItem(context, id, credential.pat, field, operations);
2475
+ const result = await updateWorkItem(context, id, credential, field, operations);
1915
2476
  formatOutput(result, options, field);
1916
2477
  } catch (err) {
1917
2478
  handleCommandError(err, id, context, "write");
@@ -1922,7 +2483,7 @@ function createSetMdFieldCommand() {
1922
2483
  }
1923
2484
 
1924
2485
  // src/commands/upsert.ts
1925
- import { existsSync as existsSync3, readFileSync as readFileSync4, unlinkSync } from "fs";
2486
+ import { existsSync as existsSync4, readFileSync as readFileSync4, unlinkSync } from "fs";
1926
2487
  import { Command as Command10 } from "commander";
1927
2488
 
1928
2489
  // src/services/task-document.ts
@@ -2086,7 +2647,7 @@ function loadSourceContent(options) {
2086
2647
  return { content: options.content };
2087
2648
  }
2088
2649
  const filePath = options.file;
2089
- if (!existsSync3(filePath)) {
2650
+ if (!existsSync4(filePath)) {
2090
2651
  fail2(`File not found: ${filePath}`);
2091
2652
  }
2092
2653
  try {
@@ -2222,15 +2783,15 @@ function createUpsertCommand() {
2222
2783
  ensureTitleForCreate(document.fields);
2223
2784
  }
2224
2785
  const operations = toPatchOperations(document.fields, action);
2225
- const credential = await requirePat(context.org);
2786
+ const credential = await requireAuthCredential(context.org);
2226
2787
  let writeResult;
2227
2788
  if (action === "created") {
2228
- writeResult = await createWorkItem(context, createType, credential.pat, operations);
2789
+ writeResult = await createWorkItem(context, createType, credential, operations);
2229
2790
  } else {
2230
2791
  if (id === void 0) {
2231
2792
  fail2("Work item ID is required for updates.");
2232
2793
  }
2233
- writeResult = await applyWorkItemPatch(context, id, credential.pat, operations);
2794
+ writeResult = await applyWorkItemPatch(context, id, credential, operations);
2234
2795
  }
2235
2796
  const result = buildUpsertResult(
2236
2797
  action,
@@ -2288,8 +2849,8 @@ function createListFieldsCommand() {
2288
2849
  let context;
2289
2850
  try {
2290
2851
  context = resolveContext(options);
2291
- const credential = await requirePat(context.org);
2292
- const fields = await getWorkItemFields(context, id, credential.pat);
2852
+ const credential = await requireAuthCredential(context.org);
2853
+ const fields = await getWorkItemFields(context, id, credential);
2293
2854
  if (options.json) {
2294
2855
  process.stdout.write(JSON.stringify({ id, fields }, null, 2) + "\n");
2295
2856
  } else {
@@ -2331,6 +2892,31 @@ function buildPullRequestStatusesUrl(context, repo, prId) {
2331
2892
  url.searchParams.set("api-version", "7.1");
2332
2893
  return url;
2333
2894
  }
2895
+ function buildProjectUrl(context) {
2896
+ const url = new URL(
2897
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/_apis/projects/${encodeURIComponent(context.project)}`
2898
+ );
2899
+ url.searchParams.set("api-version", "7.1");
2900
+ return url;
2901
+ }
2902
+ function buildPolicyEvaluationsUrl(context, projectId, prId) {
2903
+ const url = new URL(
2904
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/policy/evaluations`
2905
+ );
2906
+ url.searchParams.set("api-version", "7.1");
2907
+ url.searchParams.set("artifactId", `vstfs:///CodeReview/CodeReviewId/${projectId}/${prId}`);
2908
+ return url;
2909
+ }
2910
+ function buildPullRequestBuildsUrl(context, prId) {
2911
+ const url = new URL(
2912
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/build/builds`
2913
+ );
2914
+ url.searchParams.set("branchName", `refs/pull/${prId}/merge`);
2915
+ url.searchParams.set("queryOrder", "queueTimeDescending");
2916
+ url.searchParams.set("$top", "50");
2917
+ url.searchParams.set("api-version", "7.1");
2918
+ return url;
2919
+ }
2334
2920
  function mapPullRequest(repo, pullRequest) {
2335
2921
  return {
2336
2922
  id: pullRequest.pullRequestId,
@@ -2343,9 +2929,9 @@ function mapPullRequest(repo, pullRequest) {
2343
2929
  url: pullRequest._links?.web?.href ?? null
2344
2930
  };
2345
2931
  }
2346
- function mapPullRequestCheckName(status) {
2347
- const genre = status.context?.genre?.trim();
2348
- const name = status.context?.name?.trim();
2932
+ function mapPullRequestCheckName(status2) {
2933
+ const genre = status2.context?.genre?.trim();
2934
+ const name = status2.context?.name?.trim();
2349
2935
  if (genre && name) {
2350
2936
  return `${genre}/${name}`;
2351
2937
  }
@@ -2355,21 +2941,80 @@ function mapPullRequestCheckName(status) {
2355
2941
  if (genre) {
2356
2942
  return genre;
2357
2943
  }
2358
- return `Status #${status.id}`;
2944
+ return `Status #${status2.id}`;
2945
+ }
2946
+ function mapPullRequestCheck(status2) {
2947
+ if (status2.state === "notApplicable" || status2.state === "notSet") {
2948
+ return null;
2949
+ }
2950
+ return {
2951
+ id: status2.id,
2952
+ state: status2.state,
2953
+ name: mapPullRequestCheckName(status2),
2954
+ description: status2.description ?? null,
2955
+ targetUrl: status2.targetUrl ?? null,
2956
+ createdBy: status2.createdBy?.displayName ?? null,
2957
+ createdAt: status2.creationDate ?? null,
2958
+ updatedAt: status2.updatedDate ?? null,
2959
+ source: "status"
2960
+ };
2961
+ }
2962
+ function mapPolicyEvaluationState(status2) {
2963
+ switch (status2) {
2964
+ case "approved":
2965
+ return "succeeded";
2966
+ case "rejected":
2967
+ return "failed";
2968
+ case "running":
2969
+ case "queued":
2970
+ return "pending";
2971
+ case "notApplicable":
2972
+ case "notSet":
2973
+ case void 0:
2974
+ return null;
2975
+ default:
2976
+ return status2;
2977
+ }
2978
+ }
2979
+ function mapBuildToCheckState(build) {
2980
+ if (build.status !== "completed") {
2981
+ return "pending";
2982
+ }
2983
+ switch (build.result) {
2984
+ case "succeeded":
2985
+ case "partiallySucceeded":
2986
+ return "succeeded";
2987
+ case "failed":
2988
+ return "failed";
2989
+ case "canceled":
2990
+ return "error";
2991
+ default:
2992
+ return "pending";
2993
+ }
2994
+ }
2995
+ function mapPolicyEvaluationName(evaluation) {
2996
+ const display = evaluation.configuration?.settings?.displayName?.trim() || evaluation.configuration?.type?.displayName?.trim();
2997
+ if (display) {
2998
+ return display;
2999
+ }
3000
+ return `Policy ${evaluation.configuration?.id ?? evaluation.evaluationId ?? "?"}`;
2359
3001
  }
2360
- function mapPullRequestCheck(status) {
2361
- if (status.state === "notApplicable" || status.state === "notSet") {
3002
+ function mapPolicyEvaluationCheck(evaluation) {
3003
+ const state = mapPolicyEvaluationState(evaluation.status);
3004
+ if (state === null) {
2362
3005
  return null;
2363
3006
  }
2364
3007
  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
3008
+ id: evaluation.configuration?.id ?? 0,
3009
+ state,
3010
+ name: mapPolicyEvaluationName(evaluation),
3011
+ description: null,
3012
+ targetUrl: null,
3013
+ createdBy: null,
3014
+ createdAt: null,
3015
+ updatedAt: null,
3016
+ source: "policy",
3017
+ isBlocking: evaluation.configuration?.isBlocking ?? null
2373
3018
  };
2374
3019
  }
2375
3020
  function mapComment(comment) {
@@ -2389,24 +3034,28 @@ function mapThread(thread) {
2389
3034
  if (comments.length === 0) {
2390
3035
  return null;
2391
3036
  }
3037
+ const line = thread.threadContext?.rightFileStart?.line ?? thread.threadContext?.leftFileStart?.line ?? null;
2392
3038
  return {
2393
3039
  id: thread.id,
2394
- status: thread.status,
3040
+ status: thread.status ?? "unknown",
2395
3041
  threadContext: thread.threadContext?.filePath ?? null,
3042
+ line,
2396
3043
  comments
2397
3044
  };
2398
3045
  }
2399
3046
  function toActiveCommentThread(thread) {
3047
+ const line = thread.threadContext?.rightFileStart?.line ?? thread.threadContext?.leftFileStart?.line ?? null;
2400
3048
  return {
2401
3049
  id: thread.id,
2402
- status: thread.status,
3050
+ status: thread.status ?? "unknown",
2403
3051
  threadContext: thread.threadContext?.filePath ?? null,
3052
+ line,
2404
3053
  comments: thread.comments.map(mapComment).filter((comment) => comment !== null)
2405
3054
  };
2406
3055
  }
2407
3056
  var RESOLVED_THREAD_STATUSES = /* @__PURE__ */ new Set(["fixed", "wontFix", "closed", "byDesign"]);
2408
- function isThreadResolved(status) {
2409
- return RESOLVED_THREAD_STATUSES.has(status);
3057
+ function isThreadResolved(status2) {
3058
+ return RESOLVED_THREAD_STATUSES.has(status2);
2410
3059
  }
2411
3060
  async function readJsonResponse(response) {
2412
3061
  if (!response.ok) {
@@ -2414,7 +3063,7 @@ async function readJsonResponse(response) {
2414
3063
  }
2415
3064
  return response.json();
2416
3065
  }
2417
- async function patchThreadStatus(context, repo, pat, prId, threadId, status) {
3066
+ async function patchThreadStatus(context, repo, cred, prId, threadId, status2) {
2418
3067
  const url = new URL(
2419
3068
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}/threads/${threadId}`
2420
3069
  );
@@ -2422,41 +3071,74 @@ async function patchThreadStatus(context, repo, pat, prId, threadId, status) {
2422
3071
  const response = await fetchWithErrors(url.toString(), {
2423
3072
  method: "PATCH",
2424
3073
  headers: {
2425
- ...authHeaders(pat),
3074
+ ...authHeaders(cred),
2426
3075
  "Content-Type": "application/json"
2427
3076
  },
2428
- body: JSON.stringify({ status })
3077
+ body: JSON.stringify({ status: status2 })
2429
3078
  });
2430
3079
  const data = await readJsonResponse(response);
2431
3080
  return toActiveCommentThread(data);
2432
3081
  }
2433
- async function getPullRequestById(context, repo, pat, prId) {
3082
+ async function getPullRequestById(context, repo, cred, prId) {
2434
3083
  const url = new URL(
2435
3084
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}`
2436
3085
  );
2437
3086
  url.searchParams.set("api-version", "7.1");
2438
- const response = await fetchWithErrors(url.toString(), { headers: authHeaders(pat) });
3087
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
2439
3088
  const data = await readJsonResponse(response);
2440
3089
  return mapPullRequest(repo, data);
2441
3090
  }
2442
- async function listPullRequests(context, repo, pat, sourceBranch, opts) {
3091
+ async function listPullRequests(context, repo, cred, sourceBranch, opts) {
2443
3092
  const response = await fetchWithErrors(
2444
3093
  buildPullRequestsUrl(context, repo, sourceBranch, opts).toString(),
2445
- { headers: authHeaders(pat) }
3094
+ { headers: authHeaders(cred) }
2446
3095
  );
2447
3096
  const data = await readJsonResponse(response);
2448
3097
  return data.value.map((pullRequest) => mapPullRequest(repo, pullRequest));
2449
3098
  }
2450
- async function getPullRequestChecks(context, repo, pat, prId) {
3099
+ async function getPullRequestChecks(context, repo, cred, prId) {
2451
3100
  const response = await fetchWithErrors(
2452
3101
  buildPullRequestStatusesUrl(context, repo, prId).toString(),
2453
- { headers: authHeaders(pat) }
3102
+ { headers: authHeaders(cred) }
2454
3103
  );
2455
3104
  const data = await readJsonResponse(response);
2456
3105
  return data.value.map(mapPullRequestCheck).filter((check) => check !== null);
2457
3106
  }
2458
- async function openPullRequest(context, repo, pat, sourceBranch, title, description) {
2459
- const existing = await listPullRequests(context, repo, pat, sourceBranch, {
3107
+ async function resolveProjectId(context, cred) {
3108
+ const response = await fetchWithErrors(buildProjectUrl(context).toString(), {
3109
+ headers: authHeaders(cred)
3110
+ });
3111
+ const data = await readJsonResponse(response);
3112
+ return data.id;
3113
+ }
3114
+ async function getPullRequestPolicyEvaluations(context, cred, projectId, prId) {
3115
+ const response = await fetchWithErrors(
3116
+ buildPolicyEvaluationsUrl(context, projectId, prId).toString(),
3117
+ { headers: authHeaders(cred) }
3118
+ );
3119
+ const data = await readJsonResponse(response);
3120
+ return data.value.map(mapPolicyEvaluationCheck).filter((check) => check !== null);
3121
+ }
3122
+ async function getPullRequestBuilds(context, cred, prId) {
3123
+ const response = await fetchWithErrors(buildPullRequestBuildsUrl(context, prId).toString(), {
3124
+ headers: authHeaders(cred)
3125
+ });
3126
+ const data = await readJsonResponse(response);
3127
+ return data.value.map((build) => ({
3128
+ id: build.id,
3129
+ state: mapBuildToCheckState(build),
3130
+ name: build.definition?.name ?? `Build #${build.id}`,
3131
+ description: null,
3132
+ targetUrl: build._links?.web?.href ?? null,
3133
+ createdBy: null,
3134
+ createdAt: build.queueTime ?? null,
3135
+ updatedAt: build.finishTime ?? null,
3136
+ source: "build",
3137
+ isBlocking: null
3138
+ }));
3139
+ }
3140
+ async function openPullRequest(context, repo, cred, sourceBranch, title, description) {
3141
+ const existing = await listPullRequests(context, repo, cred, sourceBranch, {
2460
3142
  status: "active",
2461
3143
  targetBranch: "develop"
2462
3144
  });
@@ -2484,7 +3166,7 @@ async function openPullRequest(context, repo, pat, sourceBranch, title, descript
2484
3166
  const response = await fetchWithErrors(url.toString(), {
2485
3167
  method: "POST",
2486
3168
  headers: {
2487
- ...authHeaders(pat),
3169
+ ...authHeaders(cred),
2488
3170
  "Content-Type": "application/json"
2489
3171
  },
2490
3172
  body: JSON.stringify(payload)
@@ -2497,12 +3179,12 @@ async function openPullRequest(context, repo, pat, sourceBranch, title, descript
2497
3179
  pullRequest: mapPullRequest(repo, data)
2498
3180
  };
2499
3181
  }
2500
- async function getPullRequestThreads(context, repo, pat, prId) {
3182
+ async function getPullRequestThreads(context, repo, cred, prId) {
2501
3183
  const url = new URL(
2502
3184
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/git/repositories/${encodeURIComponent(repo)}/pullRequests/${prId}/threads`
2503
3185
  );
2504
3186
  url.searchParams.set("api-version", "7.1");
2505
- const response = await fetchWithErrors(url.toString(), { headers: authHeaders(pat) });
3187
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
2506
3188
  const data = await readJsonResponse(response);
2507
3189
  return data.value.map(mapThread).filter((thread) => thread !== null);
2508
3190
  }
@@ -2515,6 +3197,21 @@ function parsePositivePrNumber(raw) {
2515
3197
  const n = Number.parseInt(raw, 10);
2516
3198
  return Number.isFinite(n) && n > 0 ? n : null;
2517
3199
  }
3200
+ 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.";
3201
+ function configureUnwrappedHelp(command) {
3202
+ return command.configureHelp({ helpWidth: 1e3 });
3203
+ }
3204
+ function autoDetectZeroMatch(branch) {
3205
+ 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.`;
3206
+ }
3207
+ function autoDetectMultiMatch(branch, ids) {
3208
+ return `Multiple open pull requests match branch ${branch}: ${ids.map((id) => `#${id}`).join(", ")}. Re-run with --pr-number to choose.`;
3209
+ }
3210
+ function writeContractError(line) {
3211
+ process.stderr.write(`${line}
3212
+ `);
3213
+ process.exitCode = 1;
3214
+ }
2518
3215
  function formatBranchName(refName) {
2519
3216
  return refName.startsWith("refs/heads/") ? refName.slice("refs/heads/".length) : refName;
2520
3217
  }
@@ -2548,34 +3245,101 @@ function handlePrCommandError(err, context, mode = "read") {
2548
3245
  }
2549
3246
  writeError(error.message);
2550
3247
  }
2551
- function formatPullRequestChecks(checks) {
3248
+ function formatPullRequestChecks(checks, checksError) {
3249
+ if (checksError) {
3250
+ return [`Checks: unable to retrieve (${checksError})`];
3251
+ }
2552
3252
  if (checks.length === 0) {
2553
3253
  return ["Checks: none reported by Azure DevOps"];
2554
3254
  }
2555
3255
  const lines = ["Checks:"];
2556
3256
  for (const check of checks) {
2557
- lines.push(`- [${check.state}] ${check.name}`);
3257
+ const optionalTag = check.isBlocking === false ? " [optional]" : "";
3258
+ lines.push(`- [${check.state}] ${check.name}${optionalTag}`);
2558
3259
  if ((check.state === "failed" || check.state === "error") && check.description) {
2559
3260
  lines.push(` Detail: ${check.description}`);
2560
3261
  }
2561
3262
  }
2562
3263
  return lines;
2563
3264
  }
3265
+ function countCodeComments(threads) {
3266
+ let open = 0;
3267
+ let closed = 0;
3268
+ for (const thread of threads) {
3269
+ if (thread.threadContext === null) {
3270
+ continue;
3271
+ }
3272
+ if (isThreadResolved(thread.status)) {
3273
+ closed += 1;
3274
+ } else {
3275
+ open += 1;
3276
+ }
3277
+ }
3278
+ return { open, closed };
3279
+ }
3280
+ function formatCodeCommentCounts(counts) {
3281
+ return `Code comments: ${counts.open} open, ${counts.closed} closed`;
3282
+ }
2564
3283
  function formatPullRequestBlock(pullRequest) {
2565
3284
  return [
2566
3285
  `#${pullRequest.id} [${pullRequest.status}] ${pullRequest.title}`,
2567
3286
  `${formatBranchName(pullRequest.sourceRefName)} -> ${formatBranchName(pullRequest.targetRefName)}`,
2568
3287
  pullRequest.url ?? "\u2014",
2569
- ...formatPullRequestChecks(pullRequest.checks)
3288
+ ...formatPullRequestChecks(pullRequest.checks, pullRequest.checksError),
3289
+ formatCodeCommentCounts(pullRequest.codeCommentCounts)
2570
3290
  ].join("\n");
2571
3291
  }
2572
- function threadStatusLabel(status) {
2573
- return isThreadResolved(status) ? "resolved" : status;
3292
+ async function buildPullRequestStatusEntry(context, repo, cred, pullRequest, projectId) {
3293
+ let statusChecks = [];
3294
+ let statusOk = true;
3295
+ try {
3296
+ statusChecks = await getPullRequestChecks(context, repo, cred, pullRequest.id);
3297
+ } catch {
3298
+ statusOk = false;
3299
+ }
3300
+ let policyChecks = [];
3301
+ let policyOk = true;
3302
+ if (projectId === null) {
3303
+ policyOk = false;
3304
+ } else {
3305
+ try {
3306
+ policyChecks = await getPullRequestPolicyEvaluations(context, cred, projectId, pullRequest.id);
3307
+ } catch {
3308
+ policyOk = false;
3309
+ }
3310
+ }
3311
+ let buildChecks = [];
3312
+ let buildsOk = true;
3313
+ try {
3314
+ buildChecks = await getPullRequestBuilds(context, cred, pullRequest.id);
3315
+ } catch {
3316
+ buildsOk = false;
3317
+ }
3318
+ let codeCommentCounts;
3319
+ try {
3320
+ const threads = await getPullRequestThreads(context, repo, cred, pullRequest.id);
3321
+ codeCommentCounts = countCodeComments(threads);
3322
+ } catch {
3323
+ codeCommentCounts = { open: 0, closed: 0 };
3324
+ }
3325
+ const checks = [...statusChecks, ...policyChecks, ...buildChecks];
3326
+ const checksError = checks.length === 0 && (!statusOk || !policyOk || !buildsOk) ? "Azure DevOps request failed" : null;
3327
+ return {
3328
+ ...pullRequest,
3329
+ checks,
3330
+ codeCommentCounts,
3331
+ checksError
3332
+ };
3333
+ }
3334
+ function threadStatusLabel(status2) {
3335
+ return isThreadResolved(status2) ? "resolved" : status2;
2574
3336
  }
2575
3337
  function formatThreads(prId, title, threads) {
2576
3338
  const lines = [`Comment threads for pull request #${prId}: ${title}`];
2577
3339
  for (const thread of threads) {
2578
- lines.push("", `Thread #${thread.id} [${threadStatusLabel(thread.status)}] ${thread.threadContext ?? "(general)"}`);
3340
+ const lineSuffix = thread.line === null ? "" : `:${thread.line}`;
3341
+ const location = thread.threadContext ? `${thread.threadContext}${lineSuffix}` : "(general)";
3342
+ lines.push("", `Thread #${thread.id} [${threadStatusLabel(thread.status)}] ${location}`);
2579
3343
  for (const comment of thread.comments) {
2580
3344
  lines.push(` ${comment.author ?? "Unknown"}: ${comment.content}`);
2581
3345
  }
@@ -2587,12 +3351,12 @@ async function resolvePrCommandContext(options, resolveOpts = {}) {
2587
3351
  const context = resolveContext(options);
2588
3352
  const repo = detectRepoName();
2589
3353
  const branch = requireBranch ? getCurrentBranch() : null;
2590
- const credential = await requirePat(context.org);
3354
+ const credential = await requireAuthCredential(context.org);
2591
3355
  return {
2592
3356
  context,
2593
3357
  repo,
2594
3358
  branch,
2595
- pat: credential.pat
3359
+ pat: credential
2596
3360
  };
2597
3361
  }
2598
3362
  function createPrStatusCommand() {
@@ -2605,11 +3369,16 @@ function createPrStatusCommand() {
2605
3369
  context = resolved.context;
2606
3370
  const branch = resolved.branch;
2607
3371
  const pullRequests = await listPullRequests(resolved.context, resolved.repo, resolved.pat, branch);
3372
+ let projectId = null;
3373
+ try {
3374
+ projectId = await resolveProjectId(resolved.context, resolved.pat);
3375
+ } catch {
3376
+ projectId = null;
3377
+ }
2608
3378
  const pullRequestsWithChecks = await Promise.all(
2609
- pullRequests.map(async (pullRequest) => ({
2610
- ...pullRequest,
2611
- checks: await getPullRequestChecks(resolved.context, resolved.repo, resolved.pat, pullRequest.id)
2612
- }))
3379
+ pullRequests.map(
3380
+ async (pullRequest) => buildPullRequestStatusEntry(resolved.context, resolved.repo, resolved.pat, pullRequest, projectId)
3381
+ )
2613
3382
  );
2614
3383
  const result = { branch, repository: resolved.repo, pullRequests: pullRequestsWithChecks };
2615
3384
  if (options.json) {
@@ -2690,7 +3459,7 @@ ${result.pullRequest.url ?? "\u2014"}
2690
3459
  }
2691
3460
  function createPrCommentsCommand() {
2692
3461
  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) => {
3462
+ configureUnwrappedHelp(command).description("List pull request comment threads for the current branch").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--pr-number <N>", PR_NUMBER_HELP).option("--hide-resolved", "hide threads whose status is resolved / won't fix / closed / by design").option("--exclude-resolved", "alias of --hide-resolved: exclude resolved / won't fix / closed / by design threads").option("--code-related-only", "show only threads anchored to a file/line; omit general discussion threads").option("--json", "output JSON").action(async (options) => {
2694
3463
  validateOrgProjectPair(options);
2695
3464
  let context;
2696
3465
  let explicitPrId = null;
@@ -2722,19 +3491,22 @@ function createPrCommentsCommand() {
2722
3491
  status: "active"
2723
3492
  });
2724
3493
  if (pullRequests.length === 0) {
2725
- writeError(`No active pull request found for branch ${resolved.branch}.`);
3494
+ writeContractError(autoDetectZeroMatch(resolved.branch));
2726
3495
  return;
2727
3496
  }
2728
3497
  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.`);
3498
+ writeContractError(autoDetectMultiMatch(resolved.branch, pullRequests.map((pr) => pr.id)));
2731
3499
  return;
2732
3500
  }
2733
3501
  pullRequest = pullRequests[0];
2734
3502
  branchLabel = resolved.branch;
2735
3503
  }
3504
+ const hideResolved = options.hideResolved === true || options.excludeResolved === true;
3505
+ const codeRelatedOnly = options.codeRelatedOnly === true;
2736
3506
  const allThreads = await getPullRequestThreads(resolved.context, resolved.repo, resolved.pat, pullRequest.id);
2737
- const threads = options.hideResolved ? allThreads.filter((thread) => !isThreadResolved(thread.status)) : allThreads;
3507
+ const threads = allThreads.filter(
3508
+ (thread) => (!hideResolved || !isThreadResolved(thread.status)) && (!codeRelatedOnly || thread.threadContext !== null)
3509
+ );
2738
3510
  const result = { branch: branchLabel, pullRequest, threads };
2739
3511
  if (options.json) {
2740
3512
  process.stdout.write(`${JSON.stringify(result, null, 2)}
@@ -2742,9 +3514,18 @@ function createPrCommentsCommand() {
2742
3514
  return;
2743
3515
  }
2744
3516
  if (threads.length === 0) {
2745
- if (options.hideResolved && allThreads.length > 0) {
2746
- process.stdout.write(`Pull request #${pullRequest.id} has no unresolved comment threads (${allThreads.length} resolved thread${allThreads.length === 1 ? "" : "s"} hidden by --hide-resolved).
2747
- `);
3517
+ if (allThreads.length > 0 && (hideResolved || codeRelatedOnly)) {
3518
+ const filters = [];
3519
+ if (codeRelatedOnly) {
3520
+ filters.push("code-related");
3521
+ }
3522
+ if (hideResolved) {
3523
+ filters.push("unresolved");
3524
+ }
3525
+ process.stdout.write(
3526
+ `Pull request #${pullRequest.id} has no ${filters.join(" ")} comment threads (filtered from ${allThreads.length} thread${allThreads.length === 1 ? "" : "s"}).
3527
+ `
3528
+ );
2748
3529
  } else {
2749
3530
  process.stdout.write(`Pull request #${pullRequest.id} has no comment threads.
2750
3531
  `);
@@ -2791,12 +3572,11 @@ async function resolveThreadTarget(threadIdRaw, options) {
2791
3572
  status: "active"
2792
3573
  });
2793
3574
  if (pullRequests.length === 0) {
2794
- writeError(`No active pull request found for branch ${resolved.branch}.`);
3575
+ writeContractError(autoDetectZeroMatch(resolved.branch));
2795
3576
  return null;
2796
3577
  }
2797
3578
  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.`);
3579
+ writeContractError(autoDetectMultiMatch(resolved.branch, pullRequests.map((pr) => pr.id)));
2800
3580
  return null;
2801
3581
  }
2802
3582
  pullRequest = pullRequests[0];
@@ -2864,14 +3644,14 @@ async function runThreadStateChange(threadIdRaw, options, direction) {
2864
3644
  }
2865
3645
  function createPrCommentResolveCommand() {
2866
3646
  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) => {
3647
+ 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
3648
  await runThreadStateChange(threadIdRaw, options, "resolve");
2869
3649
  });
2870
3650
  return command;
2871
3651
  }
2872
3652
  function createPrCommentReopenCommand() {
2873
3653
  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) => {
3654
+ 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
3655
  await runThreadStateChange(threadIdRaw, options, "reopen");
2876
3656
  });
2877
3657
  return command;
@@ -2887,38 +3667,950 @@ function createPrCommand() {
2887
3667
  return command;
2888
3668
  }
2889
3669
 
2890
- // src/commands/comments.ts
3670
+ // src/commands/pipeline.ts
2891
3671
  import { Command as Command13 } from "commander";
3672
+
3673
+ // src/services/pipeline-client.ts
3674
+ var API_VERSION = "7.1";
3675
+ function orgProjectBase(context) {
3676
+ return `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}`;
3677
+ }
3678
+ function withApiVersion(url) {
3679
+ url.searchParams.set("api-version", API_VERSION);
3680
+ return url;
3681
+ }
3682
+ async function readJsonResponse2(response) {
3683
+ if (!response.ok) {
3684
+ throw new Error(`HTTP_${response.status}`);
3685
+ }
3686
+ return await response.json();
3687
+ }
3688
+ function mapPipeline(pipeline) {
3689
+ return {
3690
+ id: pipeline.id,
3691
+ name: pipeline.name,
3692
+ folder: pipeline.folder?.trim() ? pipeline.folder : null
3693
+ };
3694
+ }
3695
+ function mapRunState(state) {
3696
+ if (state === "inProgress" || state === "completed") {
3697
+ return state;
3698
+ }
3699
+ return "unknown";
3700
+ }
3701
+ function mapRunResult(result) {
3702
+ if (result === "succeeded" || result === "failed" || result === "canceled") {
3703
+ return result;
3704
+ }
3705
+ if (result === "partiallySucceeded") {
3706
+ return "failed";
3707
+ }
3708
+ return null;
3709
+ }
3710
+ function mapBuildState(status2) {
3711
+ if (status2 === "completed") return "completed";
3712
+ if (status2 === void 0 || status2 === "none") return "unknown";
3713
+ return "inProgress";
3714
+ }
3715
+ function mapBuildSummary(build) {
3716
+ return {
3717
+ id: build.id,
3718
+ name: build.buildNumber ?? null,
3719
+ state: mapBuildState(build.status),
3720
+ result: mapRunResult(build.result),
3721
+ createdDate: build.queueTime ?? build.startTime ?? null,
3722
+ finishedDate: build.finishTime ?? null,
3723
+ sourceBranch: build.sourceBranch ?? null,
3724
+ sourceCommit: build.sourceVersion ?? null
3725
+ };
3726
+ }
3727
+ function normalizeRef(branch) {
3728
+ return branch.startsWith("refs/") ? branch : `refs/heads/${branch}`;
3729
+ }
3730
+ async function getPipelineDefinitions(context, cred) {
3731
+ const url = withApiVersion(new URL(`${orgProjectBase(context)}/_apis/pipelines`));
3732
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
3733
+ const data = await readJsonResponse2(response);
3734
+ return data.value.map(mapPipeline);
3735
+ }
3736
+ var COMMIT_LOOKBACK = 200;
3737
+ async function getPipelineRuns(context, cred, query) {
3738
+ const url = withApiVersion(new URL(`${orgProjectBase(context)}/_apis/build/builds`));
3739
+ if (query.definitionId !== void 0) {
3740
+ url.searchParams.set("definitions", String(query.definitionId));
3741
+ }
3742
+ if (query.prNumber !== void 0) {
3743
+ url.searchParams.set("branchName", `refs/pull/${query.prNumber}/merge`);
3744
+ } else if (query.branch) {
3745
+ url.searchParams.set("branchName", normalizeRef(query.branch));
3746
+ }
3747
+ url.searchParams.set("queryOrder", "queueTimeDescending");
3748
+ url.searchParams.set("$top", String(query.commit ? COMMIT_LOOKBACK : query.top));
3749
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
3750
+ const data = await readJsonResponse2(response);
3751
+ let runs = data.value.map(mapBuildSummary);
3752
+ if (query.commit) {
3753
+ const needle = query.commit.toLowerCase();
3754
+ runs = runs.filter((run) => run.sourceCommit?.toLowerCase().startsWith(needle));
3755
+ }
3756
+ return runs.slice(0, query.top);
3757
+ }
3758
+ async function runPipeline(context, cred, pipelineId, opts) {
3759
+ const url = withApiVersion(
3760
+ new URL(`${orgProjectBase(context)}/_apis/pipelines/${pipelineId}/runs`)
3761
+ );
3762
+ const body = {};
3763
+ if (opts.branch) {
3764
+ const refName = opts.branch.startsWith("refs/") ? opts.branch : `refs/heads/${opts.branch}`;
3765
+ body.resources = { repositories: { self: { refName } } };
3766
+ }
3767
+ if (opts.parameters && Object.keys(opts.parameters).length > 0) {
3768
+ body.templateParameters = opts.parameters;
3769
+ }
3770
+ const response = await fetchWithErrors(url.toString(), {
3771
+ method: "POST",
3772
+ headers: { ...authHeaders(cred), "Content-Type": "application/json" },
3773
+ body: JSON.stringify(body)
3774
+ });
3775
+ const data = await readJsonResponse2(response);
3776
+ return {
3777
+ id: data.id,
3778
+ state: mapRunState(data.state),
3779
+ webUrl: data._links?.web?.href ?? null
3780
+ };
3781
+ }
3782
+ function buildUrl(context, buildId) {
3783
+ return withApiVersion(new URL(`${orgProjectBase(context)}/_apis/build/builds/${buildId}`));
3784
+ }
3785
+ async function getBuild(context, cred, buildId) {
3786
+ const response = await fetchWithErrors(buildUrl(context, buildId).toString(), {
3787
+ headers: authHeaders(cred)
3788
+ });
3789
+ return readJsonResponse2(response);
3790
+ }
3791
+ async function getBuildStatus(context, cred, buildId) {
3792
+ const build = await getBuild(context, cred, buildId);
3793
+ return { state: mapBuildState(build.status), result: mapRunResult(build.result) };
3794
+ }
3795
+ async function getBuildTimeline(context, cred, buildId) {
3796
+ const url = withApiVersion(
3797
+ new URL(`${orgProjectBase(context)}/_apis/build/builds/${buildId}/timeline`)
3798
+ );
3799
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
3800
+ const data = await readJsonResponse2(response);
3801
+ const records = data.records ?? [];
3802
+ const errors = [];
3803
+ const stages = [];
3804
+ const jobs = [];
3805
+ const logSteps = /* @__PURE__ */ new Map();
3806
+ for (const record of records) {
3807
+ for (const issue of record.issues ?? []) {
3808
+ if (issue.type === "error" && issue.message) {
3809
+ errors.push({ message: issue.message, source: record.name ?? null });
3810
+ }
3811
+ }
3812
+ if (record.name && record.log?.id !== void 0) {
3813
+ logSteps.set(record.log.id, record.name);
3814
+ }
3815
+ if (!record.name) continue;
3816
+ const status2 = {
3817
+ name: record.name,
3818
+ state: record.state ?? "unknown",
3819
+ result: record.result ?? null
3820
+ };
3821
+ if (record.type === "Stage") {
3822
+ stages.push(status2);
3823
+ } else if (record.type === "Job") {
3824
+ jobs.push({ startTime: record.startTime, status: status2 });
3825
+ }
3826
+ }
3827
+ jobs.sort((a, b) => {
3828
+ if (a.startTime === b.startTime) return 0;
3829
+ if (a.startTime === void 0) return 1;
3830
+ if (b.startTime === void 0) return -1;
3831
+ return a.startTime < b.startTime ? -1 : 1;
3832
+ });
3833
+ return { errors, stages, jobs: jobs.map((j) => j.status), logSteps };
3834
+ }
3835
+ async function listTestRuns(context, cred, buildId) {
3836
+ const url = withApiVersion(new URL(`${orgProjectBase(context)}/_apis/test/runs`));
3837
+ url.searchParams.set("buildUri", `vstfs:///Build/Build/${buildId}`);
3838
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
3839
+ const data = await readJsonResponse2(response);
3840
+ return data.value;
3841
+ }
3842
+ async function getTestSummary(context, cred, buildId) {
3843
+ const testRuns = await listTestRuns(context, cred, buildId);
3844
+ let total = 0;
3845
+ let failed = 0;
3846
+ for (const run of testRuns) {
3847
+ const runTotal = run.totalTests ?? 0;
3848
+ total += runTotal;
3849
+ const passedOrSkipped = (run.passedTests ?? 0) + (run.notApplicableTests ?? 0) + (run.incompleteTests ?? 0);
3850
+ failed += Math.max(0, runTotal - passedOrSkipped);
3851
+ }
3852
+ if (total === 0) {
3853
+ return { present: false, total: 0, failed: 0, failedTests: [] };
3854
+ }
3855
+ return { present: true, total, failed, failedTests: [] };
3856
+ }
3857
+ var MAX_FAILED_TESTS = 50;
3858
+ async function getFailedTests(context, cred, buildId) {
3859
+ const testRuns = await listTestRuns(context, cred, buildId);
3860
+ const failed = [];
3861
+ for (const testRun of testRuns) {
3862
+ if (failed.length >= MAX_FAILED_TESTS) break;
3863
+ const resultsUrl = withApiVersion(
3864
+ new URL(`${orgProjectBase(context)}/_apis/test/runs/${testRun.id}/results`)
3865
+ );
3866
+ resultsUrl.searchParams.set("outcomes", "Failed");
3867
+ resultsUrl.searchParams.set("$top", String(MAX_FAILED_TESTS - failed.length));
3868
+ const resultsResponse = await fetchWithErrors(resultsUrl.toString(), {
3869
+ headers: authHeaders(cred)
3870
+ });
3871
+ const resultsData = await readJsonResponse2(resultsResponse);
3872
+ for (const result of resultsData.value) {
3873
+ failed.push({
3874
+ name: result.testCaseTitle ?? result.automatedTestName ?? "(unnamed test)",
3875
+ errorMessage: result.errorMessage ?? null
3876
+ });
3877
+ }
3878
+ }
3879
+ return failed;
3880
+ }
3881
+ function secondsBetween(start, finish) {
3882
+ if (!start || !finish) return null;
3883
+ const ms = Date.parse(finish) - Date.parse(start);
3884
+ return Number.isFinite(ms) ? Math.round(ms / 1e3) : null;
3885
+ }
3886
+ async function getRunDetail(context, cred, buildId) {
3887
+ const build = await getBuild(context, cred, buildId);
3888
+ let errors = [];
3889
+ let stages = [];
3890
+ let jobs = [];
3891
+ let errorsAvailable = true;
3892
+ try {
3893
+ const timeline = await getBuildTimeline(context, cred, buildId);
3894
+ errors = timeline.errors;
3895
+ stages = timeline.stages;
3896
+ jobs = timeline.jobs;
3897
+ } catch {
3898
+ errorsAvailable = false;
3899
+ }
3900
+ let tests = { present: false, total: 0, failed: 0, failedTests: [] };
3901
+ let testsAvailable = true;
3902
+ try {
3903
+ tests = await getTestSummary(context, cred, buildId);
3904
+ if (tests.failed > 0) {
3905
+ try {
3906
+ tests = { ...tests, failedTests: await getFailedTests(context, cred, buildId) };
3907
+ } catch {
3908
+ }
3909
+ }
3910
+ } catch {
3911
+ testsAvailable = false;
3912
+ }
3913
+ return {
3914
+ id: build.id,
3915
+ name: build.buildNumber ?? null,
3916
+ state: mapBuildState(build.status),
3917
+ result: mapRunResult(build.result),
3918
+ // createdDate is the queue time, matching the run-list mapping.
3919
+ createdDate: build.queueTime ?? build.startTime ?? null,
3920
+ startedDate: build.startTime ?? null,
3921
+ finishedDate: build.finishTime ?? null,
3922
+ durationSeconds: secondsBetween(build.startTime, build.finishTime),
3923
+ reason: build.reason ?? null,
3924
+ requestedFor: build.requestedFor?.displayName ?? null,
3925
+ sourceBranch: build.sourceBranch ?? null,
3926
+ sourceCommit: build.sourceVersion ?? null,
3927
+ webUrl: build._links?.web?.href ?? null,
3928
+ errors,
3929
+ errorsAvailable,
3930
+ stages,
3931
+ jobs,
3932
+ tests,
3933
+ testsAvailable
3934
+ };
3935
+ }
3936
+ async function getRunLogs(context, cred, buildId) {
3937
+ const url = withApiVersion(
3938
+ new URL(`${orgProjectBase(context)}/_apis/build/builds/${buildId}/logs`)
3939
+ );
3940
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
3941
+ const data = await readJsonResponse2(response);
3942
+ let logSteps = /* @__PURE__ */ new Map();
3943
+ try {
3944
+ logSteps = (await getBuildTimeline(context, cred, buildId)).logSteps;
3945
+ } catch {
3946
+ }
3947
+ return data.value.map((log) => ({
3948
+ id: log.id,
3949
+ createdOn: log.createdOn ?? null,
3950
+ lineCount: log.lineCount ?? null,
3951
+ step: logSteps.get(log.id) ?? null
3952
+ }));
3953
+ }
3954
+ async function getRunLog(context, cred, buildId, logId) {
3955
+ const url = withApiVersion(
3956
+ new URL(`${orgProjectBase(context)}/_apis/build/builds/${buildId}/logs/${logId}`)
3957
+ );
3958
+ const response = await fetchWithErrors(url.toString(), { headers: authHeaders(cred) });
3959
+ if (!response.ok) {
3960
+ throw new Error(`HTTP_${response.status}`);
3961
+ }
3962
+ return response.text();
3963
+ }
3964
+
3965
+ // src/commands/pipeline.ts
3966
+ var EXIT_FAILED = 1;
3967
+ var EXIT_CANCELED = 2;
3968
+ var EXIT_TIMEOUT = 124;
2892
3969
  function writeError2(message) {
2893
3970
  process.stderr.write(`Error: ${message}
2894
3971
  `);
2895
- process.exit(1);
3972
+ process.exitCode = 1;
2896
3973
  }
2897
- function formatCommentHeader(comment) {
2898
- const author = comment.author ?? "Unknown";
2899
- const timestamp = comment.modifiedAt ?? comment.createdAt ?? "Unknown time";
2900
- return `Comment #${comment.id} by ${author} at ${timestamp}`;
3974
+ function handlePipelineError(err, context) {
3975
+ const error = err instanceof Error ? err : new Error(String(err));
3976
+ if (error.message === "AUTH_FAILED") {
3977
+ writeError2('Authentication failed. Check that your credential is valid and has the "Build (Read)" scope.');
3978
+ return;
3979
+ }
3980
+ if (error.message === "PERMISSION_DENIED") {
3981
+ writeError2(`Access denied. Your credential may lack pipeline permissions for project "${context?.project}".`);
3982
+ return;
3983
+ }
3984
+ if (error.message === "NETWORK_ERROR") {
3985
+ writeError2("Could not connect to Azure DevOps. Check your network connection.");
3986
+ return;
3987
+ }
3988
+ if (error.message.startsWith("NOT_FOUND")) {
3989
+ writeError2(`Resource not found in ${context?.org}/${context?.project}.`);
3990
+ return;
3991
+ }
3992
+ if (error.message.startsWith("HTTP_")) {
3993
+ writeError2(`Azure DevOps request failed with ${error.message}.`);
3994
+ return;
3995
+ }
3996
+ writeError2(error.message);
2901
3997
  }
2902
- function formatComments(result, convertMarkdown) {
2903
- const lines = [`Comments for work item #${result.workItemId}`];
2904
- for (const comment of result.comments) {
2905
- const text = convertMarkdown ? toMarkdown(comment.text) : comment.text;
2906
- lines.push("", formatCommentHeader(comment), text);
3998
+ function parsePositiveId(raw) {
3999
+ if (!/^\d+$/.test(raw)) return null;
4000
+ const n = Number.parseInt(raw, 10);
4001
+ return Number.isFinite(n) && n > 0 ? n : null;
4002
+ }
4003
+ function parseOptionalCount(value, flag) {
4004
+ if (value === void 0) return void 0;
4005
+ const parsed = parsePositiveId(value);
4006
+ if (parsed === null) {
4007
+ writeError2(`Invalid ${flag} "${value}"; expected a positive integer.`);
4008
+ return null;
2907
4009
  }
2908
- return lines.join("\n");
4010
+ return parsed;
2909
4011
  }
2910
- function createCommentsListCommand() {
4012
+ function formatBranchName2(refName) {
4013
+ if (!refName) return "\u2014";
4014
+ return refName.startsWith("refs/heads/") ? refName.slice("refs/heads/".length) : refName;
4015
+ }
4016
+ async function resolvePipelineContext(options) {
4017
+ const context = resolveContext(options);
4018
+ const cred = await requireAuthCredential(context.org);
4019
+ return { context, cred };
4020
+ }
4021
+ function sleep(ms) {
4022
+ return new Promise((resolve2) => {
4023
+ setTimeout(resolve2, ms);
4024
+ });
4025
+ }
4026
+ function formatTable(rows, rightAlign = /* @__PURE__ */ new Set()) {
4027
+ const widths = [];
4028
+ for (const row of rows) {
4029
+ row.forEach((cell, i) => {
4030
+ widths[i] = Math.max(widths[i] ?? 0, cell.length);
4031
+ });
4032
+ }
4033
+ return rows.map(
4034
+ (row) => row.map((cell, i) => rightAlign.has(i) ? cell.padStart(widths[i]) : cell.padEnd(widths[i])).join(" ").trimEnd()
4035
+ ).join("\n");
4036
+ }
4037
+ function createPipelineListCommand() {
2911
4038
  const command = new Command13("list");
2912
- command.description("List visible comments for a work item").argument("<id>", "work item ID").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output JSON").option("--markdown", "convert HTML comment bodies to markdown").action(async (idStr, options) => {
4039
+ command.description("List Azure DevOps pipeline definitions").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--filter <name>", "filter definitions by name (case-insensitive substring)").option("--json", "output JSON").action(async (options) => {
2913
4040
  validateOrgProjectPair(options);
2914
- const id = parseWorkItemId(idStr);
2915
4041
  let context;
2916
4042
  try {
2917
- context = resolveContext(options);
2918
- const credential = await requirePat(context.org);
2919
- const result = await listWorkItemComments(context, id, credential.pat);
4043
+ const resolved = await resolvePipelineContext(options);
4044
+ context = resolved.context;
4045
+ let definitions = await getPipelineDefinitions(resolved.context, resolved.cred);
4046
+ if (options.filter) {
4047
+ const needle = options.filter.toLowerCase();
4048
+ definitions = definitions.filter((d) => d.name.toLowerCase().includes(needle));
4049
+ }
2920
4050
  if (options.json) {
2921
- process.stdout.write(`${JSON.stringify(result, null, 2)}
4051
+ process.stdout.write(`${JSON.stringify(definitions, null, 2)}
4052
+ `);
4053
+ return;
4054
+ }
4055
+ if (definitions.length === 0) {
4056
+ process.stdout.write("No pipelines found.\n");
4057
+ return;
4058
+ }
4059
+ const hasFolder = definitions.some((d) => d.folder);
4060
+ const rows = definitions.map(
4061
+ (d) => hasFolder ? [String(d.id), d.name, d.folder ?? ""] : [String(d.id), d.name]
4062
+ );
4063
+ process.stdout.write(`${formatTable(rows, /* @__PURE__ */ new Set([0]))}
4064
+ `);
4065
+ } catch (err) {
4066
+ handlePipelineError(err, context);
4067
+ }
4068
+ });
4069
+ return command;
4070
+ }
4071
+ function runRow(run) {
4072
+ const status2 = run.result ? `${run.state}/${run.result}` : run.state;
4073
+ return [
4074
+ String(run.id),
4075
+ `[${status2}]`,
4076
+ run.createdDate ?? "\u2014",
4077
+ formatBranchName2(run.sourceBranch),
4078
+ run.sourceCommit ? run.sourceCommit.slice(0, 8) : "\u2014"
4079
+ ];
4080
+ }
4081
+ var COMMIT_SHA_PATTERN = /^[0-9a-f]{6,40}$/i;
4082
+ function parseGetRunsInputs(defIdRaw, options) {
4083
+ let defId;
4084
+ if (defIdRaw !== void 0) {
4085
+ const parsed = parsePositiveId(defIdRaw);
4086
+ if (parsed === null) {
4087
+ writeError2(`Invalid definition id "${defIdRaw}"; expected a positive integer.`);
4088
+ return null;
4089
+ }
4090
+ defId = parsed;
4091
+ } else if (options.commit === void 0 && options.pr === void 0) {
4092
+ writeError2("Definition id is required unless --commit or --pr is given.");
4093
+ return null;
4094
+ }
4095
+ const limit = parseOptionalCount(options.limit, "--limit");
4096
+ if (limit === null) return null;
4097
+ const prNumber = parseOptionalCount(options.pr, "--pr");
4098
+ if (prNumber === null) return null;
4099
+ if (options.commit !== void 0 && !COMMIT_SHA_PATTERN.test(options.commit)) {
4100
+ writeError2(`Invalid --commit "${options.commit}"; expected 6-40 hex characters.`);
4101
+ return null;
4102
+ }
4103
+ if (options.branch !== void 0 && prNumber !== void 0) {
4104
+ writeError2("Use either --branch or --pr, not both.");
4105
+ return null;
4106
+ }
4107
+ return { defId, limit: limit ?? 10, prNumber };
4108
+ }
4109
+ function createPipelineGetRunsCommand() {
4110
+ const command = new Command13("get-runs");
4111
+ command.description("List recent runs for a pipeline definition (newest first)").argument("[def_id]", "pipeline definition id (optional with --commit or --pr)").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--limit <n>", "maximum number of runs to show (default 10)").option("--branch <branch>", "only show runs for this source branch").option("--commit <sha>", "only show runs that built this commit (full or abbreviated SHA)").option("--pr <number>", "only show runs for this pull request").option("--json", "output JSON").action(async (defIdRaw, options) => {
4112
+ validateOrgProjectPair(options);
4113
+ const inputs = parseGetRunsInputs(defIdRaw, options);
4114
+ if (inputs === null) {
4115
+ return;
4116
+ }
4117
+ let context;
4118
+ try {
4119
+ const resolved = await resolvePipelineContext(options);
4120
+ context = resolved.context;
4121
+ const runs = await getPipelineRuns(resolved.context, resolved.cred, {
4122
+ definitionId: inputs.defId,
4123
+ branch: options.branch,
4124
+ prNumber: inputs.prNumber,
4125
+ commit: options.commit,
4126
+ top: inputs.limit
4127
+ });
4128
+ if (options.json) {
4129
+ process.stdout.write(`${JSON.stringify(runs, null, 2)}
4130
+ `);
4131
+ return;
4132
+ }
4133
+ if (runs.length === 0) {
4134
+ process.stdout.write(
4135
+ inputs.defId === void 0 ? "No runs found matching the filters.\n" : `No runs found for pipeline ${inputs.defId}.
4136
+ `
4137
+ );
4138
+ return;
4139
+ }
4140
+ process.stdout.write(`${formatTable(runs.map(runRow), /* @__PURE__ */ new Set([0]))}
4141
+ `);
4142
+ } catch (err) {
4143
+ handlePipelineError(err, context);
4144
+ }
4145
+ });
4146
+ return command;
4147
+ }
4148
+ function applyWaitExitCode(result) {
4149
+ if (result.timedOut) {
4150
+ process.exitCode = EXIT_TIMEOUT;
4151
+ return;
4152
+ }
4153
+ switch (result.result) {
4154
+ case "succeeded":
4155
+ return;
4156
+ case "canceled":
4157
+ process.exitCode = EXIT_CANCELED;
4158
+ return;
4159
+ case "failed":
4160
+ default:
4161
+ process.exitCode = EXIT_FAILED;
4162
+ }
4163
+ }
4164
+ function createPipelineWaitCommand() {
4165
+ const command = new Command13("wait");
4166
+ command.description("Wait for a pipeline run to finish; exit code reflects the result (0 success, non-zero otherwise)").argument("<run_id>", "pipeline run id").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--timeout <seconds>", "maximum seconds to wait (default 1800)").option("--poll-interval <seconds>", "seconds between status checks (default 5)").option("--json", "output JSON").action(async (runIdRaw, options) => {
4167
+ validateOrgProjectPair(options);
4168
+ const runId = parsePositiveId(runIdRaw);
4169
+ if (runId === null) {
4170
+ writeError2(`Invalid run id "${runIdRaw}"; expected a positive integer.`);
4171
+ return;
4172
+ }
4173
+ const timeoutSec = options.timeout === void 0 ? 1800 : Number(options.timeout);
4174
+ const pollSec = options.pollInterval === void 0 ? 5 : Number(options.pollInterval);
4175
+ if (!Number.isFinite(timeoutSec) || timeoutSec < 0) {
4176
+ writeError2(`Invalid --timeout "${options.timeout}"; expected a non-negative number.`);
4177
+ return;
4178
+ }
4179
+ if (!Number.isFinite(pollSec) || pollSec <= 0) {
4180
+ writeError2(`Invalid --poll-interval "${options.pollInterval}"; expected a positive number.`);
4181
+ return;
4182
+ }
4183
+ let context;
4184
+ try {
4185
+ const resolved = await resolvePipelineContext(options);
4186
+ context = resolved.context;
4187
+ const deadline = Date.now() + timeoutSec * 1e3;
4188
+ let waitResult = null;
4189
+ for (; ; ) {
4190
+ const status2 = await getBuildStatus(resolved.context, resolved.cred, runId);
4191
+ if (status2.state === "completed") {
4192
+ waitResult = { id: runId, state: status2.state, result: status2.result, timedOut: false };
4193
+ break;
4194
+ }
4195
+ if (Date.now() >= deadline) {
4196
+ waitResult = { id: runId, state: status2.state, result: status2.result, timedOut: true };
4197
+ break;
4198
+ }
4199
+ await sleep(pollSec * 1e3);
4200
+ }
4201
+ applyWaitExitCode(waitResult);
4202
+ if (options.json) {
4203
+ process.stdout.write(`${JSON.stringify(waitResult, null, 2)}
4204
+ `);
4205
+ return;
4206
+ }
4207
+ if (waitResult.timedOut) {
4208
+ process.stdout.write(`Run ${runId} did not finish within ${timeoutSec}s (still ${waitResult.state}).
4209
+ `);
4210
+ } else {
4211
+ process.stdout.write(`Run ${runId} finished: ${waitResult.result ?? waitResult.state}.
4212
+ `);
4213
+ }
4214
+ } catch (err) {
4215
+ handlePipelineError(err, context);
4216
+ }
4217
+ });
4218
+ return command;
4219
+ }
4220
+ function timelineRows(items, available) {
4221
+ if (!available) {
4222
+ return [" unavailable"];
4223
+ }
4224
+ if (items.length === 0) {
4225
+ return [" (none)"];
4226
+ }
4227
+ return items.map((item) => ` - ${item.name} [${item.result ?? item.state}]`);
4228
+ }
4229
+ function formatDuration(totalSeconds) {
4230
+ const h = Math.floor(totalSeconds / 3600);
4231
+ const m = Math.floor(totalSeconds % 3600 / 60);
4232
+ const s = totalSeconds % 60;
4233
+ if (h > 0) return `${h}h${m}m${s}s`;
4234
+ if (m > 0) return `${m}m${s}s`;
4235
+ return `${s}s`;
4236
+ }
4237
+ function errorRows(detail) {
4238
+ if (!detail.errorsAvailable) {
4239
+ return [" unavailable"];
4240
+ }
4241
+ if (detail.errors.length === 0) {
4242
+ return [" (none)"];
4243
+ }
4244
+ return detail.errors.map((error) => {
4245
+ const source = error.source ? `[${error.source}] ` : "";
4246
+ return ` - ${source}${error.message}`;
4247
+ });
4248
+ }
4249
+ function failedTestRow(test) {
4250
+ if (!test.errorMessage) {
4251
+ return ` - ${test.name}`;
4252
+ }
4253
+ const firstLine = test.errorMessage.split("\n", 1)[0].trim();
4254
+ return ` - ${test.name}: ${firstLine}`;
4255
+ }
4256
+ function testRows(detail) {
4257
+ if (!detail.testsAvailable) {
4258
+ return [" unavailable"];
4259
+ }
4260
+ if (detail.tests.present) {
4261
+ return [
4262
+ ` ${detail.tests.failed} failing of ${detail.tests.total}`,
4263
+ ...detail.tests.failedTests.map(failedTestRow)
4264
+ ];
4265
+ }
4266
+ return [" no tests present"];
4267
+ }
4268
+ function formatRunDetail(detail) {
4269
+ const status2 = detail.result ? `${detail.state}/${detail.result}` : detail.state;
4270
+ const name = detail.name ? ` ${detail.name}` : "";
4271
+ const duration = detail.durationSeconds == null ? "\u2014" : formatDuration(detail.durationSeconds);
4272
+ return [
4273
+ `Run #${detail.id} [${status2}]${name}`,
4274
+ `Queued: ${detail.createdDate ?? "\u2014"} Started: ${detail.startedDate ?? "\u2014"} Finished: ${detail.finishedDate ?? "\u2014"}`,
4275
+ `Duration: ${duration} Reason: ${detail.reason ?? "\u2014"} Requested for: ${detail.requestedFor ?? "\u2014"}`,
4276
+ `Branch: ${formatBranchName2(detail.sourceBranch)} Commit: ${detail.sourceCommit ?? "unavailable"}`,
4277
+ ...detail.webUrl ? [`Link: ${detail.webUrl}`] : [],
4278
+ "",
4279
+ "Stages:",
4280
+ ...timelineRows(detail.stages, detail.errorsAvailable),
4281
+ "",
4282
+ "Jobs:",
4283
+ ...timelineRows(detail.jobs, detail.errorsAvailable),
4284
+ "",
4285
+ "Errors:",
4286
+ ...errorRows(detail),
4287
+ "",
4288
+ "Tests:",
4289
+ ...testRows(detail)
4290
+ ].join("\n");
4291
+ }
4292
+ function createPipelineGetRunDetailCommand() {
4293
+ const command = new Command13("get-run-detail");
4294
+ command.description("Show a detailed summary of a single pipeline run (errors, failing tests, stages)").argument("<run_id>", "pipeline run id").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output JSON").action(async (runIdRaw, options) => {
4295
+ validateOrgProjectPair(options);
4296
+ const runId = parsePositiveId(runIdRaw);
4297
+ if (runId === null) {
4298
+ writeError2(`Invalid run id "${runIdRaw}"; expected a positive integer.`);
4299
+ return;
4300
+ }
4301
+ let context;
4302
+ try {
4303
+ const resolved = await resolvePipelineContext(options);
4304
+ context = resolved.context;
4305
+ const detail = await getRunDetail(resolved.context, resolved.cred, runId);
4306
+ if (options.json) {
4307
+ process.stdout.write(`${JSON.stringify(detail, null, 2)}
4308
+ `);
4309
+ return;
4310
+ }
4311
+ process.stdout.write(`${formatRunDetail(detail)}
4312
+ `);
4313
+ } catch (err) {
4314
+ handlePipelineError(err, context);
4315
+ }
4316
+ });
4317
+ return command;
4318
+ }
4319
+ function grepWithContext(lines, grep, context) {
4320
+ const include = /* @__PURE__ */ new Set();
4321
+ lines.forEach((line, i) => {
4322
+ if (grep.test(line)) {
4323
+ for (let j = Math.max(0, i - context); j <= Math.min(lines.length - 1, i + context); j++) {
4324
+ include.add(j);
4325
+ }
4326
+ }
4327
+ });
4328
+ const selected = [];
4329
+ let prev = -1;
4330
+ for (const i of [...include].sort((a, b) => a - b)) {
4331
+ if (selected.length > 0 && i > prev + 1) {
4332
+ selected.push("--");
4333
+ }
4334
+ selected.push(lines[i]);
4335
+ prev = i;
4336
+ }
4337
+ return selected;
4338
+ }
4339
+ function filterLogLines(content, grep, tail, context) {
4340
+ let lines = content.split("\n");
4341
+ if (lines.at(-1) === "") {
4342
+ lines.pop();
4343
+ }
4344
+ if (grep) {
4345
+ lines = context > 0 ? grepWithContext(lines, grep, context) : lines.filter((line) => grep.test(line));
4346
+ }
4347
+ if (tail !== void 0 && lines.length > tail) {
4348
+ lines = lines.slice(-tail);
4349
+ }
4350
+ return lines;
4351
+ }
4352
+ function parseLogFilters(options) {
4353
+ if (options.logId !== void 0 && options.step !== void 0) {
4354
+ writeError2("Use either --log-id or --step, not both.");
4355
+ return null;
4356
+ }
4357
+ const selectsSingleLog = options.logId !== void 0 || options.step !== void 0;
4358
+ const slices = options.tail !== void 0 || options.grep !== void 0 || options.context !== void 0;
4359
+ if (slices && !selectsSingleLog) {
4360
+ writeError2("--tail, --grep, and --context require --log-id or --step.");
4361
+ return null;
4362
+ }
4363
+ if (options.context !== void 0 && options.grep === void 0) {
4364
+ writeError2("--context requires --grep.");
4365
+ return null;
4366
+ }
4367
+ const tail = parseOptionalCount(options.tail, "--tail");
4368
+ if (tail === null) return null;
4369
+ const contextLines = parseOptionalCount(options.context, "--context");
4370
+ if (contextLines === null) return null;
4371
+ let grep;
4372
+ if (options.grep !== void 0) {
4373
+ try {
4374
+ grep = new RegExp(options.grep);
4375
+ } catch {
4376
+ writeError2(`Invalid --grep "${options.grep}"; expected a valid regular expression.`);
4377
+ return null;
4378
+ }
4379
+ }
4380
+ return { tail, contextLines: contextLines ?? 0, grep };
4381
+ }
4382
+ function chooseStepLog(logs, step, runId) {
4383
+ const needle = step.toLowerCase();
4384
+ const matches = logs.filter((l) => l.step?.toLowerCase().includes(needle));
4385
+ const exact = matches.filter((l) => l.step?.toLowerCase() === needle);
4386
+ const chosen = exact.length === 1 ? exact : matches;
4387
+ if (chosen.length === 0) {
4388
+ writeError2(`No log matches step "${step}" in run ${runId}.`);
4389
+ return null;
4390
+ }
4391
+ if (chosen.length > 1) {
4392
+ const candidates = chosen.map((l) => `${l.id} (${l.step})`).join(", ");
4393
+ writeError2(`Step "${step}" matches multiple logs: ${candidates}. Be more specific or use --log-id.`);
4394
+ return null;
4395
+ }
4396
+ return chosen[0].id;
4397
+ }
4398
+ async function resolveRequestedLogId(resolved, runId, options) {
4399
+ if (options.logId !== void 0) {
4400
+ const parsed = parsePositiveId(options.logId);
4401
+ if (parsed === null) {
4402
+ writeError2(`Invalid --log-id "${options.logId}"; expected a positive integer.`);
4403
+ return null;
4404
+ }
4405
+ return parsed;
4406
+ }
4407
+ if (options.step === void 0) {
4408
+ return void 0;
4409
+ }
4410
+ const allLogs = await getRunLogs(resolved.context, resolved.cred, runId);
4411
+ return chooseStepLog(allLogs, options.step, runId);
4412
+ }
4413
+ function printSingleLog(content, filters) {
4414
+ if (filters.grep !== void 0 || filters.tail !== void 0) {
4415
+ const lines = filterLogLines(content, filters.grep, filters.tail, filters.contextLines);
4416
+ if (lines.length > 0) {
4417
+ process.stdout.write(`${lines.join("\n")}
4418
+ `);
4419
+ }
4420
+ return;
4421
+ }
4422
+ process.stdout.write(content.endsWith("\n") ? content : `${content}
4423
+ `);
4424
+ }
4425
+ function createPipelineLogsCommand() {
4426
+ const command = new Command13("logs");
4427
+ command.description("List a pipeline run's logs, or print a specific log with --log-id").argument("<run_id>", "pipeline run id").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--log-id <id>", "print the content of this log id").option("--step <name>", "print the log of the step/job matching this name (case-insensitive substring)").option("--tail <n>", "with --log-id/--step, print only the last N lines").option("--grep <pattern>", "with --log-id/--step, print only lines matching this regular expression").option("--context <n>", "with --grep, also print N lines around each match (grep -C)").option("--json", "output JSON").action(async (runIdRaw, options) => {
4428
+ validateOrgProjectPair(options);
4429
+ const runId = parsePositiveId(runIdRaw);
4430
+ if (runId === null) {
4431
+ writeError2(`Invalid run id "${runIdRaw}"; expected a positive integer.`);
4432
+ return;
4433
+ }
4434
+ const filters = parseLogFilters(options);
4435
+ if (filters === null) {
4436
+ return;
4437
+ }
4438
+ let context;
4439
+ try {
4440
+ const resolved = await resolvePipelineContext(options);
4441
+ context = resolved.context;
4442
+ const logId = await resolveRequestedLogId(resolved, runId, options);
4443
+ if (logId === null) {
4444
+ return;
4445
+ }
4446
+ if (logId !== void 0) {
4447
+ const content = await getRunLog(resolved.context, resolved.cred, runId, logId);
4448
+ printSingleLog(content, filters);
4449
+ return;
4450
+ }
4451
+ const logs = await getRunLogs(resolved.context, resolved.cred, runId);
4452
+ if (options.json) {
4453
+ process.stdout.write(`${JSON.stringify(logs, null, 2)}
4454
+ `);
4455
+ return;
4456
+ }
4457
+ if (logs.length === 0) {
4458
+ process.stdout.write(`No logs found for run ${runId}.
4459
+ `);
4460
+ return;
4461
+ }
4462
+ const rows = logs.map((l) => [
4463
+ String(l.id),
4464
+ l.createdOn ?? "\u2014",
4465
+ l.lineCount == null ? "" : `${l.lineCount} lines`,
4466
+ l.step ?? ""
4467
+ ]);
4468
+ process.stdout.write(`${formatTable(rows, /* @__PURE__ */ new Set([0]))}
4469
+ `);
4470
+ } catch (err) {
4471
+ handlePipelineError(err, context);
4472
+ }
4473
+ });
4474
+ return command;
4475
+ }
4476
+ function createPipelineTestsCommand() {
4477
+ const command = new Command13("tests");
4478
+ command.description("Show a run's test results: summary plus failing tests by name (no log grepping needed)").argument("<run_id>", "pipeline run id").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--failed", "list only the failing tests").option("--json", "output JSON").action(async (runIdRaw, options) => {
4479
+ validateOrgProjectPair(options);
4480
+ const runId = parsePositiveId(runIdRaw);
4481
+ if (runId === null) {
4482
+ writeError2(`Invalid run id "${runIdRaw}"; expected a positive integer.`);
4483
+ return;
4484
+ }
4485
+ let context;
4486
+ try {
4487
+ const resolved = await resolvePipelineContext(options);
4488
+ context = resolved.context;
4489
+ const summary = await getTestSummary(resolved.context, resolved.cred, runId);
4490
+ const failedTests = summary.failed > 0 ? await getFailedTests(resolved.context, resolved.cred, runId) : [];
4491
+ if (options.json) {
4492
+ process.stdout.write(`${JSON.stringify({ ...summary, failedTests }, null, 2)}
4493
+ `);
4494
+ return;
4495
+ }
4496
+ if (!summary.present) {
4497
+ process.stdout.write(`No test results published for run ${runId}.
4498
+ `);
4499
+ return;
4500
+ }
4501
+ if (!options.failed) {
4502
+ process.stdout.write(`Run #${runId}: ${summary.failed} failing of ${summary.total} tests
4503
+ `);
4504
+ }
4505
+ if (failedTests.length > 0) {
4506
+ process.stdout.write(`${failedTests.map(failedTestRow).join("\n")}
4507
+ `);
4508
+ } else if (options.failed) {
4509
+ process.stdout.write("No failing tests.\n");
4510
+ }
4511
+ } catch (err) {
4512
+ handlePipelineError(err, context);
4513
+ }
4514
+ });
4515
+ return command;
4516
+ }
4517
+ function parseParameters(values) {
4518
+ const result = {};
4519
+ for (const entry of values ?? []) {
4520
+ const eq = entry.indexOf("=");
4521
+ if (eq <= 0) {
4522
+ return null;
4523
+ }
4524
+ const key = entry.slice(0, eq);
4525
+ const value = entry.slice(eq + 1);
4526
+ result[key] = value;
4527
+ }
4528
+ return result;
4529
+ }
4530
+ function collectParameter(value, previous) {
4531
+ return previous.concat([value]);
4532
+ }
4533
+ function createPipelineStartCommand() {
4534
+ const command = new Command13("start");
4535
+ command.description("Queue a new run of a pipeline definition").argument("<def_id>", "pipeline definition id").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--branch <branch>", "branch to run against (default: pipeline default branch)").option("--parameter <key=value>", "template parameter (repeatable)", collectParameter, []).option("--json", "output JSON").action(async (defIdRaw, options) => {
4536
+ validateOrgProjectPair(options);
4537
+ const defId = parsePositiveId(defIdRaw);
4538
+ if (defId === null) {
4539
+ writeError2(`Invalid definition id "${defIdRaw}"; expected a positive integer.`);
4540
+ return;
4541
+ }
4542
+ const parameters = parseParameters(options.parameter);
4543
+ if (parameters === null) {
4544
+ writeError2("Invalid --parameter; expected key=value.");
4545
+ return;
4546
+ }
4547
+ let context;
4548
+ try {
4549
+ const resolved = await resolvePipelineContext(options);
4550
+ context = resolved.context;
4551
+ const result = await runPipeline(resolved.context, resolved.cred, defId, {
4552
+ branch: options.branch,
4553
+ parameters
4554
+ });
4555
+ if (options.json) {
4556
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
4557
+ `);
4558
+ return;
4559
+ }
4560
+ process.stdout.write(`Queued run #${result.id} [${result.state}]
4561
+ ${result.webUrl ?? "\u2014"}
4562
+ `);
4563
+ } catch (err) {
4564
+ handlePipelineError(err, context);
4565
+ }
4566
+ });
4567
+ return command;
4568
+ }
4569
+ function createPipelineCommand() {
4570
+ const command = new Command13("pipeline");
4571
+ command.description("Manage Azure DevOps pipelines");
4572
+ command.addCommand(createPipelineListCommand());
4573
+ command.addCommand(createPipelineGetRunsCommand());
4574
+ command.addCommand(createPipelineWaitCommand());
4575
+ command.addCommand(createPipelineGetRunDetailCommand());
4576
+ command.addCommand(createPipelineLogsCommand());
4577
+ command.addCommand(createPipelineTestsCommand());
4578
+ command.addCommand(createPipelineStartCommand());
4579
+ return command;
4580
+ }
4581
+
4582
+ // src/commands/comments.ts
4583
+ import { Command as Command14 } from "commander";
4584
+ function writeError3(message) {
4585
+ process.stderr.write(`Error: ${message}
4586
+ `);
4587
+ process.exit(1);
4588
+ }
4589
+ function formatCommentHeader(comment) {
4590
+ const author = comment.author ?? "Unknown";
4591
+ const timestamp = comment.modifiedAt ?? comment.createdAt ?? "Unknown time";
4592
+ return `Comment #${comment.id} by ${author} at ${timestamp}`;
4593
+ }
4594
+ function formatComments(result, convertMarkdown) {
4595
+ const lines = [`Comments for work item #${result.workItemId}`];
4596
+ for (const comment of result.comments) {
4597
+ const text = convertMarkdown ? toMarkdown(comment.text) : comment.text;
4598
+ lines.push("", formatCommentHeader(comment), text);
4599
+ }
4600
+ return lines.join("\n");
4601
+ }
4602
+ function createCommentsListCommand() {
4603
+ const command = new Command14("list");
4604
+ command.description("List visible comments for a work item").argument("<id>", "work item ID").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output JSON").option("--markdown", "convert HTML comment bodies to markdown").action(async (idStr, options) => {
4605
+ validateOrgProjectPair(options);
4606
+ const id = parseWorkItemId(idStr);
4607
+ let context;
4608
+ try {
4609
+ context = resolveContext(options);
4610
+ const credential = await requireAuthCredential(context.org);
4611
+ const result = await listWorkItemComments(context, id, credential);
4612
+ if (options.json) {
4613
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
2922
4614
  `);
2923
4615
  return;
2924
4616
  }
@@ -2936,19 +4628,19 @@ function createCommentsListCommand() {
2936
4628
  return command;
2937
4629
  }
2938
4630
  function createCommentsAddCommand() {
2939
- const command = new Command13("add");
4631
+ const command = new Command14("add");
2940
4632
  command.description("Add a comment to a work item").argument("<id>", "work item ID").argument("<text>", "comment text").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output JSON").option("--markdown", "post comment as markdown").action(async (idStr, text, options) => {
2941
4633
  validateOrgProjectPair(options);
2942
4634
  const id = parseWorkItemId(idStr);
2943
4635
  if (text.trim() === "") {
2944
- writeError2("Comment text must be a non-empty string.");
4636
+ writeError3("Comment text must be a non-empty string.");
2945
4637
  }
2946
4638
  let context;
2947
4639
  try {
2948
4640
  context = resolveContext(options);
2949
- const credential = await requirePat(context.org);
4641
+ const credential = await requireAuthCredential(context.org);
2950
4642
  const format = options.markdown === true ? "markdown" : "html";
2951
- const result = await addWorkItemComment(context, id, credential.pat, text, format);
4643
+ const result = await addWorkItemComment(context, id, credential, text, format);
2952
4644
  if (options.json) {
2953
4645
  process.stdout.write(`${JSON.stringify(result, null, 2)}
2954
4646
  `);
@@ -2963,7 +4655,7 @@ function createCommentsAddCommand() {
2963
4655
  return command;
2964
4656
  }
2965
4657
  function createCommentsCommand() {
2966
- const command = new Command13("comments");
4658
+ const command = new Command14("comments");
2967
4659
  command.description("Manage Azure DevOps work item comments");
2968
4660
  command.addCommand(createCommentsListCommand());
2969
4661
  command.addCommand(createCommentsAddCommand());
@@ -2971,12 +4663,12 @@ function createCommentsCommand() {
2971
4663
  }
2972
4664
 
2973
4665
  // src/commands/download-attachment.ts
2974
- import { Command as Command14 } from "commander";
2975
- import { writeFile } from "fs/promises";
2976
- import { existsSync as existsSync4 } from "fs";
2977
- import { join as join2 } from "path";
4666
+ import { Command as Command15 } from "commander";
4667
+ import { writeFile as writeFile2 } from "fs/promises";
4668
+ import { existsSync as existsSync5 } from "fs";
4669
+ import { join as join3 } from "path";
2978
4670
  function createDownloadAttachmentCommand() {
2979
- const command = new Command14("download-attachment");
4671
+ const command = new Command15("download-attachment");
2980
4672
  command.description("Download an attachment from an Azure DevOps work item").argument("<id>", "work item ID").argument("<filename>", "name of the attachment to download").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--output <dir>", "target directory for the downloaded file").action(
2981
4673
  async (idStr, filename, options) => {
2982
4674
  const id = parseWorkItemId(idStr);
@@ -2984,14 +4676,14 @@ function createDownloadAttachmentCommand() {
2984
4676
  let context;
2985
4677
  try {
2986
4678
  context = resolveContext(options);
2987
- const credential = await requirePat(context.org);
4679
+ const credential = await requireAuthCredential(context.org);
2988
4680
  const outputDir = options.output ?? ".";
2989
- if (!existsSync4(outputDir)) {
4681
+ if (!existsSync5(outputDir)) {
2990
4682
  process.stderr.write(`Error: Output directory "${outputDir}" does not exist.
2991
4683
  `);
2992
4684
  process.exit(1);
2993
4685
  }
2994
- const workItem = await getWorkItem(context, id, credential.pat);
4686
+ const workItem = await getWorkItem(context, id, credential);
2995
4687
  const attachment = workItem.attachments?.find(
2996
4688
  (a) => a.name === filename
2997
4689
  );
@@ -3002,9 +4694,9 @@ function createDownloadAttachmentCommand() {
3002
4694
  );
3003
4695
  process.exit(1);
3004
4696
  }
3005
- const data = await downloadAttachment(attachment.url, credential.pat);
3006
- const outputPath = join2(outputDir, filename);
3007
- await writeFile(outputPath, Buffer.from(data));
4697
+ const data = await downloadAttachment(attachment.url, credential);
4698
+ const outputPath = join3(outputDir, filename);
4699
+ await writeFile2(outputPath, Buffer.from(data));
3008
4700
  process.stdout.write(
3009
4701
  `Downloaded "${filename}" (${formatFileSize(attachment.size)}) to ${outputPath}
3010
4702
  `
@@ -3017,9 +4709,415 @@ function createDownloadAttachmentCommand() {
3017
4709
  return command;
3018
4710
  }
3019
4711
 
4712
+ // src/commands/relations.ts
4713
+ import { Command as Command16 } from "commander";
4714
+
4715
+ // src/services/relations-client.ts
4716
+ var API_VERSION2 = "7.1";
4717
+ function buildRelationTypesUrl(context) {
4718
+ const url = new URL(
4719
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/_apis/wit/workitemrelationtypes`
4720
+ );
4721
+ url.searchParams.set("api-version", API_VERSION2);
4722
+ return url;
4723
+ }
4724
+ function buildWorkItemUrl2(context, id, expand) {
4725
+ const url = new URL(
4726
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
4727
+ );
4728
+ url.searchParams.set("api-version", API_VERSION2);
4729
+ if (expand) url.searchParams.set("$expand", expand);
4730
+ return url;
4731
+ }
4732
+ function buildBatchWorkItemsUrl(context, ids) {
4733
+ const url = new URL(
4734
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems`
4735
+ );
4736
+ url.searchParams.set("ids", ids.join(","));
4737
+ url.searchParams.set("fields", "System.Id,System.Title");
4738
+ url.searchParams.set("api-version", API_VERSION2);
4739
+ return url;
4740
+ }
4741
+ function mapRelationType(raw) {
4742
+ return {
4743
+ referenceName: raw.referenceName,
4744
+ name: raw.name,
4745
+ usage: raw.attributes?.usage ?? "unknown",
4746
+ enabled: raw.attributes?.enabled !== false,
4747
+ directional: raw.attributes?.directional ?? null
4748
+ };
4749
+ }
4750
+ async function readJsonResponse3(response) {
4751
+ if (!response.ok) throw new Error(`HTTP_${response.status}`);
4752
+ return await response.json();
4753
+ }
4754
+ function parseTargetId(url) {
4755
+ const match = /\/workItems\/(\d+)$/i.exec(url);
4756
+ return match ? Number(match[1]) : null;
4757
+ }
4758
+ async function getWorkItemRelationTypes(context, cred) {
4759
+ const url = buildRelationTypesUrl(context);
4760
+ const response = await fetchWithErrors(url.toString(), {
4761
+ headers: authHeaders(cred)
4762
+ });
4763
+ const body = await readJsonResponse3(response);
4764
+ return (body.value ?? []).map(mapRelationType).filter((t) => t.usage === "workItemLink" && t.enabled);
4765
+ }
4766
+ async function resolveRelationType(context, cred, alias) {
4767
+ const types = await getWorkItemRelationTypes(context, cred);
4768
+ const lower = alias.toLowerCase();
4769
+ const match = types.find((t) => t.name.toLowerCase() === lower);
4770
+ if (!match) throw new Error(`UNKNOWN_RELATION_TYPE:${alias}`);
4771
+ return match;
4772
+ }
4773
+ async function getWorkItemWithRelations(context, cred, id) {
4774
+ const url = buildWorkItemUrl2(context, id, "relations");
4775
+ const response = await fetchWithErrors(url.toString(), {
4776
+ headers: authHeaders(cred)
4777
+ });
4778
+ return readJsonResponse3(response);
4779
+ }
4780
+ async function addWorkItemRelation(context, cred, type, id1, id2) {
4781
+ if (id1 === id2) throw new Error("SELF_RELATION");
4782
+ const relType = await resolveRelationType(context, cred, type);
4783
+ const workItem = await getWorkItemWithRelations(context, cred, id1);
4784
+ const targetUrl = `https://dev.azure.com/${encodeURIComponent(context.org)}/_apis/wit/workItems/${id2}`;
4785
+ const existing = (workItem.relations ?? []).find(
4786
+ (r) => r.rel === relType.referenceName && r.url.toLowerCase() === targetUrl.toLowerCase()
4787
+ );
4788
+ if (existing) {
4789
+ return { status: "already_exists", type: relType.name, referenceName: relType.referenceName, id1, id2 };
4790
+ }
4791
+ const patchUrl = buildWorkItemUrl2(context, id1);
4792
+ const response = await fetchWithErrors(patchUrl.toString(), {
4793
+ method: "PATCH",
4794
+ headers: { ...authHeaders(cred), "Content-Type": "application/json-patch+json" },
4795
+ body: JSON.stringify([
4796
+ { op: "add", path: "/relations/-", value: { rel: relType.referenceName, url: targetUrl } }
4797
+ ])
4798
+ });
4799
+ if (!response.ok) throw new Error(`HTTP_${response.status}`);
4800
+ return { status: "added", type: relType.name, referenceName: relType.referenceName, id1, id2 };
4801
+ }
4802
+ async function removeWorkItemRelation(context, cred, type, id1, id2) {
4803
+ if (id1 === id2) throw new Error("SELF_RELATION");
4804
+ const relType = await resolveRelationType(context, cred, type);
4805
+ const workItem = await getWorkItemWithRelations(context, cred, id1);
4806
+ const relations = workItem.relations ?? [];
4807
+ const index = relations.findIndex(
4808
+ (r) => r.rel === relType.referenceName && parseTargetId(r.url) === id2
4809
+ );
4810
+ if (index === -1) {
4811
+ return { status: "not_found", type: relType.name, referenceName: relType.referenceName, id1, id2 };
4812
+ }
4813
+ const patchUrl = buildWorkItemUrl2(context, id1);
4814
+ const response = await fetchWithErrors(patchUrl.toString(), {
4815
+ method: "PATCH",
4816
+ headers: { ...authHeaders(cred), "Content-Type": "application/json-patch+json" },
4817
+ body: JSON.stringify([{ op: "remove", path: `/relations/${index}` }])
4818
+ });
4819
+ if (!response.ok) throw new Error(`HTTP_${response.status}`);
4820
+ return { status: "removed", type: relType.name, referenceName: relType.referenceName, id1, id2 };
4821
+ }
4822
+ async function listWorkItemRelations(context, cred, id) {
4823
+ const workItem = await getWorkItemWithRelations(context, cred, id);
4824
+ const allRelations = workItem.relations ?? [];
4825
+ const wiRelations = allRelations.filter(
4826
+ (r) => !r.rel.startsWith("AttachedFile") && !r.rel.startsWith("Hyperlink") && !r.rel.startsWith("ArtifactLink")
4827
+ );
4828
+ if (wiRelations.length === 0) {
4829
+ return { workItemId: id, relations: [] };
4830
+ }
4831
+ const types = await getWorkItemRelationTypes(context, cred);
4832
+ const typeNameMap = new Map(types.map((t) => [t.referenceName, t.name]));
4833
+ const targetIds = wiRelations.map((r) => parseTargetId(r.url)).filter((n) => n !== null);
4834
+ const titleMap = /* @__PURE__ */ new Map();
4835
+ if (targetIds.length > 0) {
4836
+ try {
4837
+ const batchUrl = buildBatchWorkItemsUrl(context, targetIds);
4838
+ const batchResponse = await fetchWithErrors(batchUrl.toString(), {
4839
+ headers: authHeaders(cred)
4840
+ });
4841
+ const batchBody = await readJsonResponse3(batchResponse);
4842
+ for (const item of batchBody.value ?? []) {
4843
+ titleMap.set(item.id, item.fields["System.Title"] ?? "");
4844
+ }
4845
+ } catch {
4846
+ }
4847
+ }
4848
+ const relations = wiRelations.map((r) => {
4849
+ const targetId = parseTargetId(r.url);
4850
+ return {
4851
+ rel: r.rel,
4852
+ relName: typeNameMap.get(r.rel) ?? r.rel,
4853
+ targetId: targetId ?? 0,
4854
+ targetTitle: targetId !== null ? titleMap.get(targetId) ?? null : null,
4855
+ targetUrl: r.url,
4856
+ comment: r.attributes?.comment ?? null
4857
+ };
4858
+ });
4859
+ return { workItemId: id, relations };
4860
+ }
4861
+
4862
+ // src/commands/relations.ts
4863
+ function addCommonOptions(cmd) {
4864
+ return cmd.option("--json", "Output as JSON").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project");
4865
+ }
4866
+ async function resolveCredentials(opts) {
4867
+ const context = resolveContext(opts);
4868
+ const cred = await requireAuthCredential(context.org);
4869
+ return { context, cred };
4870
+ }
4871
+ function formatRelationTypes(types) {
4872
+ if (types.length === 0) return "No work item relation types found.";
4873
+ const nameWidth = Math.max(...types.map((t) => t.name.length), 4);
4874
+ const lines = ["Available work item relation types:", ""];
4875
+ for (const t of types) {
4876
+ lines.push(`${t.name.padEnd(nameWidth + 2)}${t.referenceName}`);
4877
+ }
4878
+ return lines.join("\n");
4879
+ }
4880
+ function formatRelationsList(workItemId, relations) {
4881
+ if (relations.length === 0) return `Work item #${workItemId} has no relations.`;
4882
+ const typeWidth = Math.max(...relations.map((r) => r.relName.length), 4) + 2;
4883
+ const idWidth = Math.max(...relations.map((r) => String(r.targetId).length), 4) + 1;
4884
+ const lines = [`Relations for work item #${workItemId}:`, ""];
4885
+ for (const r of relations) {
4886
+ const typeLabel = `[${r.relName}]`.padEnd(typeWidth);
4887
+ const idLabel = `#${r.targetId}`.padEnd(idWidth);
4888
+ lines.push(`${typeLabel} ${idLabel} ${r.targetTitle ?? "(unknown)"}`);
4889
+ }
4890
+ return lines.join("\n");
4891
+ }
4892
+ function handleRelationError(err, id1) {
4893
+ const msg = err instanceof Error ? err.message : String(err);
4894
+ if (msg === "SELF_RELATION") {
4895
+ process.stderr.write(`Error: cannot relate a work item to itself (#${id1}).
4896
+ `);
4897
+ } else if (msg.startsWith("UNKNOWN_RELATION_TYPE:")) {
4898
+ const name = msg.replace("UNKNOWN_RELATION_TYPE:", "");
4899
+ process.stderr.write(
4900
+ `Error: unknown relation type "${name}". Run 'azdo relations types' to see valid names.
4901
+ `
4902
+ );
4903
+ } else if (msg.startsWith("NOT_FOUND")) {
4904
+ const target = id1 !== void 0 ? id1 : "unknown";
4905
+ process.stderr.write(`Error: work item #${target} not found.
4906
+ `);
4907
+ } else if (msg === "AUTH_FAILED") {
4908
+ process.stderr.write(
4909
+ `Error: authentication failed. Check your PAT has Work Items \u2192 Read & Write scope.
4910
+ `
4911
+ );
4912
+ } else {
4913
+ process.stderr.write(`Error: ${msg}
4914
+ `);
4915
+ }
4916
+ process.exit(1);
4917
+ }
4918
+ function parsePositiveInt(value, label) {
4919
+ const n = Number(value);
4920
+ if (!Number.isInteger(n) || n <= 0) {
4921
+ process.stderr.write(`Error: ${label} must be a positive integer.
4922
+ `);
4923
+ process.exit(1);
4924
+ }
4925
+ return n;
4926
+ }
4927
+ function createRelationsCommand() {
4928
+ const relations = new Command16("relations").description("Manage work item relations");
4929
+ addCommonOptions(
4930
+ relations.command("types").description("List all available work item relation types")
4931
+ ).action(async (opts) => {
4932
+ try {
4933
+ const { context, cred } = await resolveCredentials(opts);
4934
+ const types = await getWorkItemRelationTypes(context, cred);
4935
+ if (opts.json) {
4936
+ process.stdout.write(JSON.stringify(types, null, 2) + "\n");
4937
+ } else {
4938
+ process.stdout.write(formatRelationTypes(types) + "\n");
4939
+ }
4940
+ } catch (err) {
4941
+ handleRelationError(err);
4942
+ }
4943
+ });
4944
+ addCommonOptions(
4945
+ relations.command("add <type> <id1> <id2>").description("Add a directed relation from work item <id1> to <id2>")
4946
+ ).action(async (type, id1Str, id2Str, opts) => {
4947
+ const id1 = parsePositiveInt(id1Str, "id1");
4948
+ const id2 = parsePositiveInt(id2Str, "id2");
4949
+ try {
4950
+ const { context, cred } = await resolveCredentials(opts);
4951
+ const result = await addWorkItemRelation(context, cred, type, id1, id2);
4952
+ if (opts.json) {
4953
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
4954
+ } else if (result.status === "already_exists") {
4955
+ process.stdout.write(`Relation already exists: #${id1} --[${result.type}]--> #${id2}
4956
+ `);
4957
+ } else {
4958
+ process.stdout.write(`Added relation: #${id1} --[${result.type}]--> #${id2}
4959
+ `);
4960
+ }
4961
+ } catch (err) {
4962
+ handleRelationError(err, id1);
4963
+ }
4964
+ });
4965
+ addCommonOptions(
4966
+ relations.command("remove <type> <id1> <id2>").description("Remove a directed relation of <type> from work item <id1> to <id2>")
4967
+ ).action(async (type, id1Str, id2Str, opts) => {
4968
+ const id1 = parsePositiveInt(id1Str, "id1");
4969
+ const id2 = parsePositiveInt(id2Str, "id2");
4970
+ try {
4971
+ const { context, cred } = await resolveCredentials(opts);
4972
+ const result = await removeWorkItemRelation(context, cred, type, id1, id2);
4973
+ if (opts.json) {
4974
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
4975
+ } else if (result.status === "not_found") {
4976
+ process.stdout.write(`No relation of type '${result.type}' found between #${id1} and #${id2}
4977
+ `);
4978
+ } else {
4979
+ process.stdout.write(`Removed relation: #${id1} --[${result.type}]--> #${id2}
4980
+ `);
4981
+ }
4982
+ } catch (err) {
4983
+ handleRelationError(err, id1);
4984
+ }
4985
+ });
4986
+ addCommonOptions(
4987
+ relations.command("list <id>").description("List all work item link relations on a work item")
4988
+ ).action(async (idStr, opts) => {
4989
+ const id = parsePositiveInt(idStr, "id");
4990
+ try {
4991
+ const { context, cred } = await resolveCredentials(opts);
4992
+ const result = await listWorkItemRelations(context, cred, id);
4993
+ if (opts.json) {
4994
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
4995
+ } else {
4996
+ process.stdout.write(formatRelationsList(result.workItemId, result.relations) + "\n");
4997
+ }
4998
+ } catch (err) {
4999
+ handleRelationError(err, id);
5000
+ }
5001
+ });
5002
+ return relations;
5003
+ }
5004
+
5005
+ // src/services/update-check.ts
5006
+ import fs2 from "fs";
5007
+ import path2 from "path";
5008
+ import os from "os";
5009
+ var THROTTLE_MS = 10 * 60 * 1e3;
5010
+ var FETCH_TIMEOUT_MS = 1500;
5011
+ var REGISTRY_URL = "https://registry.npmjs.org/azdo-cli/latest";
5012
+ function getCachePath() {
5013
+ return path2.join(os.homedir(), ".azdo", "update-check.json");
5014
+ }
5015
+ function defaultReadCache() {
5016
+ try {
5017
+ return fs2.readFileSync(getCachePath(), "utf-8");
5018
+ } catch {
5019
+ return null;
5020
+ }
5021
+ }
5022
+ function defaultWriteCache(data) {
5023
+ try {
5024
+ const cachePath = getCachePath();
5025
+ fs2.mkdirSync(path2.dirname(cachePath), { recursive: true });
5026
+ fs2.writeFileSync(cachePath, data);
5027
+ } catch {
5028
+ }
5029
+ }
5030
+ function parseCache(raw) {
5031
+ if (!raw) return null;
5032
+ let parsed;
5033
+ try {
5034
+ parsed = JSON.parse(raw);
5035
+ } catch {
5036
+ return null;
5037
+ }
5038
+ if (typeof parsed !== "object" || parsed === null) return null;
5039
+ const { lastCheck, latestVersion } = parsed;
5040
+ if (typeof lastCheck !== "number" || !Number.isFinite(lastCheck)) return null;
5041
+ if (typeof latestVersion !== "string" || latestVersion.length === 0) return null;
5042
+ return { lastCheck, latestVersion };
5043
+ }
5044
+ async function defaultFetchLatest() {
5045
+ const controller = new AbortController();
5046
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
5047
+ try {
5048
+ const res = await fetch(REGISTRY_URL, { signal: controller.signal });
5049
+ if (!res.ok) return null;
5050
+ const body = await res.json();
5051
+ if (typeof body !== "object" || body === null) return null;
5052
+ const v = body.version;
5053
+ return typeof v === "string" && v.length > 0 ? v : null;
5054
+ } catch {
5055
+ return null;
5056
+ } finally {
5057
+ clearTimeout(timer);
5058
+ }
5059
+ }
5060
+ function parseVersion(v) {
5061
+ if (typeof v !== "string") return null;
5062
+ const trimmed = v.trim().replace(/^v/, "");
5063
+ const match = /^(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/.exec(trimmed);
5064
+ if (!match) return null;
5065
+ const release = [Number(match[1]), Number(match[2]), Number(match[3])];
5066
+ if (!release.every(Number.isFinite)) return null;
5067
+ return { release, prerelease: match[4] !== void 0 };
5068
+ }
5069
+ function isNewer(latest, current) {
5070
+ const a = parseVersion(latest);
5071
+ const b = parseVersion(current);
5072
+ if (!a || !b) return false;
5073
+ for (let i = 0; i < 3; i++) {
5074
+ if (a.release[i] > b.release[i]) return true;
5075
+ if (a.release[i] < b.release[i]) return false;
5076
+ }
5077
+ if (a.prerelease && !b.prerelease) return false;
5078
+ if (!a.prerelease && b.prerelease) return true;
5079
+ return false;
5080
+ }
5081
+ async function getUpdateNotice(opts) {
5082
+ try {
5083
+ const {
5084
+ enabled = true,
5085
+ now = Date.now,
5086
+ readCache = defaultReadCache,
5087
+ writeCache = defaultWriteCache,
5088
+ fetchLatest = defaultFetchLatest,
5089
+ isTTY = () => Boolean(process.stderr.isTTY),
5090
+ currentVersion = version
5091
+ } = opts ?? {};
5092
+ if (enabled === false) return null;
5093
+ if (!isTTY()) return null;
5094
+ const cache = parseCache(readCache());
5095
+ const lastCheck = cache?.lastCheck ?? 0;
5096
+ if (now() - lastCheck < THROTTLE_MS) return null;
5097
+ const latest = await fetchLatest();
5098
+ if (!latest) return null;
5099
+ writeCache(JSON.stringify({ lastCheck: now(), latestVersion: latest }));
5100
+ if (isNewer(latest, currentVersion)) {
5101
+ return `A new version of azdo-cli is available: ${currentVersion} \u2192 ${latest}. Run \`npm i -g azdo-cli\` to update.`;
5102
+ }
5103
+ return null;
5104
+ } catch {
5105
+ return null;
5106
+ }
5107
+ }
5108
+
3020
5109
  // src/index.ts
3021
- var program = new Command15();
5110
+ function exitOnEpipe(err) {
5111
+ if (err.code === "EPIPE") {
5112
+ process.exit(0);
5113
+ }
5114
+ throw err;
5115
+ }
5116
+ process.stdout.on("error", exitOnEpipe);
5117
+ process.stderr.on("error", exitOnEpipe);
5118
+ var program = new Command17();
3022
5119
  program.name("azdo").description("Azure DevOps CLI tool").version(version, "-v, --version");
5120
+ program.option("--no-update-check", "Skip the check for a newer published version");
3023
5121
  program.addCommand(createGetItemCommand());
3024
5122
  program.addCommand(createAuthCommand());
3025
5123
  program.addCommand(createClearPatCommand());
@@ -3032,10 +5130,21 @@ program.addCommand(createSetMdFieldCommand());
3032
5130
  program.addCommand(createUpsertCommand());
3033
5131
  program.addCommand(createListFieldsCommand());
3034
5132
  program.addCommand(createPrCommand());
5133
+ program.addCommand(createPipelineCommand());
3035
5134
  program.addCommand(createCommentsCommand());
3036
5135
  program.addCommand(createDownloadAttachmentCommand());
5136
+ program.addCommand(createRelationsCommand());
3037
5137
  program.showHelpAfterError();
3038
- program.parse();
3039
- if (process.argv.length <= 2) {
3040
- program.help();
5138
+ program.hook("postAction", async () => {
5139
+ const notice = await getUpdateNotice({ enabled: program.opts().updateCheck });
5140
+ if (notice) {
5141
+ process.stderr.write(notice + "\n");
5142
+ }
5143
+ });
5144
+ async function main() {
5145
+ await program.parseAsync();
5146
+ if (process.argv.length <= 2) {
5147
+ program.help();
5148
+ }
3041
5149
  }
5150
+ void main();