azdo-cli 0.18.0-develop.649 → 0.18.0-develop.675

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.
Files changed (3) hide show
  1. package/README.md +7 -0
  2. package/dist/index.js +371 -194
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -12,6 +12,7 @@ Azure DevOps CLI focused on work item read/write workflows.
12
12
  - Update work item state, assignee, or any field (`set-state`, `assign`, `set-field`)
13
13
  - Create or update work items from markdown documents (`upsert`)
14
14
  - Read and post work item comments (`comments`)
15
+ - Attach a local file to a work item, or remove a named attachment (`add-attachment`, `delete-attachment`)
15
16
  - Read/write rich-text fields as markdown (`get-md-field`, `set-md-field`)
16
17
  - Download images embedded in rich-text fields, optionally resized for LLM use (`get-item`/`get-md-field` `--download-images`, `--resize-images`)
17
18
  - Check branch pull request status, open PRs to `develop` (optionally pre-filled from a repository-defined template), list PR comment threads for any PR (`--pr-number`), resolve/reopen threads, link/unlink work items, and add/remove required or optional reviewers — all from the CLI (`pr`)
@@ -54,6 +55,12 @@ azdo upsert --type "User Story" --content $'---\nTitle: Improve markdown import
54
55
  azdo comments list 12345
55
56
  azdo comments add 12345 "Investigating the root cause now."
56
57
 
58
+ # Attach a local file to a work item, or remove one
59
+ azdo add-attachment 12345 ./screenshot.png --comment "Repro captured on staging"
60
+ azdo delete-attachment 12345 screenshot.png # prompts for confirmation
61
+ azdo delete-attachment 12345 screenshot.png --yes # skip the prompt (scripting)
62
+ azdo delete-attachment 12345 screenshot.png --id <guid> # disambiguate when the name is shared
63
+
57
64
  # Find a pull request — one API call, any branch
58
65
  azdo pr list # active PRs in the repository
59
66
  azdo pr list --branch feature/x --json # id, title, source/target, author, url, description
package/dist/index.js CHANGED
@@ -38,7 +38,7 @@ import {
38
38
  } from "./chunk-TY5KENBQ.js";
39
39
 
40
40
  // src/index.ts
41
- import { Command as Command17 } from "commander";
41
+ import { Command as Command19 } from "commander";
42
42
 
43
43
  // src/version.ts
44
44
  import { readFileSync } from "fs";
@@ -123,6 +123,176 @@ function getActiveTraceWriter() {
123
123
  return activeWriter;
124
124
  }
125
125
 
126
+ // src/services/image-download.ts
127
+ import { Jimp } from "jimp";
128
+ import { writeFile } from "fs/promises";
129
+ import { existsSync } from "fs";
130
+ import { tmpdir } from "os";
131
+ import { join } from "path";
132
+ 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})/;
133
+ function extractAttachmentGuid(url) {
134
+ const match = ATTACHMENT_GUID_RE.exec(url);
135
+ return match ? match[1].toLowerCase() : null;
136
+ }
137
+ function isAzureDevOpsAttachmentHost(hostname) {
138
+ const host = hostname.toLowerCase();
139
+ return host === "dev.azure.com" || host.endsWith(".dev.azure.com") || host.endsWith(".visualstudio.com");
140
+ }
141
+ function decodeHtmlEntities(value) {
142
+ return value.replaceAll("&quot;", '"').replaceAll("&#39;", "'").replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&amp;", "&");
143
+ }
144
+ function parseAttachmentReference(rawUrl, sourceField) {
145
+ const url = decodeHtmlEntities(rawUrl.trim());
146
+ let parsed;
147
+ try {
148
+ parsed = new URL(url);
149
+ } catch {
150
+ return null;
151
+ }
152
+ if (parsed.protocol !== "https:" || !isAzureDevOpsAttachmentHost(parsed.hostname)) {
153
+ return null;
154
+ }
155
+ const guid = extractAttachmentGuid(parsed.pathname);
156
+ if (!guid) return null;
157
+ let suggestedExtension = ".png";
158
+ const fileName = parsed.searchParams.get("fileName");
159
+ if (fileName?.includes(".")) {
160
+ suggestedExtension = fileName.slice(fileName.lastIndexOf(".")).toLowerCase();
161
+ }
162
+ return { url, sourceField, guid, suggestedExtension };
163
+ }
164
+ function extractImageReferences(content, sourceField) {
165
+ if (!content) return [];
166
+ const references = [];
167
+ const seen = /* @__PURE__ */ new Set();
168
+ const add = (rawUrl) => {
169
+ const reference = parseAttachmentReference(rawUrl, sourceField);
170
+ if (reference && !seen.has(reference.guid)) {
171
+ seen.add(reference.guid);
172
+ references.push(reference);
173
+ }
174
+ };
175
+ const imgRegex = /<img\b[^>]*?\ssrc\s*=\s*["']([^"']+)["']/gi;
176
+ let match;
177
+ while ((match = imgRegex.exec(content)) !== null) {
178
+ add(match[1]);
179
+ }
180
+ const markdownRegex = /!\[[^\]]*\]\(\s*([^)\s]+)/g;
181
+ while ((match = markdownRegex.exec(content)) !== null) {
182
+ add(match[1]);
183
+ }
184
+ return references;
185
+ }
186
+ function addImageDownloadOptions(command) {
187
+ 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)");
188
+ }
189
+ function resolveImageDownloadOptionsOrExit(flags) {
190
+ try {
191
+ return resolveImageDownloadOptions(flags);
192
+ } catch (err) {
193
+ process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
194
+ `);
195
+ process.exit(1);
196
+ }
197
+ }
198
+ function resolveImageDownloadOptions(flags) {
199
+ const wantsResize = flags.resizeImages !== void 0;
200
+ const enabled = Boolean(flags.downloadImages) || wantsResize;
201
+ let maxWidth;
202
+ if (wantsResize) {
203
+ const parsed = Number(flags.resizeImages);
204
+ if (!Number.isInteger(parsed) || parsed <= 0) {
205
+ throw new Error(
206
+ `Invalid --resize-images value "${flags.resizeImages}": must be a positive integer (max width in pixels).`
207
+ );
208
+ }
209
+ maxWidth = parsed;
210
+ }
211
+ const outputDir = flags.imagesPath ?? tmpdir();
212
+ if (flags.imagesPath !== void 0 && !existsSync(outputDir)) {
213
+ throw new Error(`Images path "${outputDir}" does not exist.`);
214
+ }
215
+ return { enabled, maxWidth, outputDir };
216
+ }
217
+ function buildImageFileName(workItemId, index, reference, resizing) {
218
+ const ext = resizing ? ".png" : reference.suggestedExtension;
219
+ return `wi-${workItemId}-${index}${ext}`;
220
+ }
221
+ async function processImageBytes(bytes, maxWidth) {
222
+ if (maxWidth === void 0) {
223
+ return { buffer: Buffer.from(bytes), resized: false, format: "original" };
224
+ }
225
+ const image = await Jimp.read(Buffer.from(bytes));
226
+ let resized = false;
227
+ if (image.bitmap.width > maxWidth) {
228
+ image.resize({ w: maxWidth });
229
+ resized = true;
230
+ }
231
+ const buffer = await image.getBuffer("image/png");
232
+ return { buffer, resized, format: "png" };
233
+ }
234
+ async function downloadImagesFromFields(fields, args, credential) {
235
+ const { workItemId, options } = args;
236
+ const resizing = options.maxWidth !== void 0;
237
+ const seen = /* @__PURE__ */ new Set();
238
+ const references = [];
239
+ for (const field of fields) {
240
+ for (const reference of extractImageReferences(field.content, field.field)) {
241
+ if (!seen.has(reference.guid)) {
242
+ seen.add(reference.guid);
243
+ references.push(reference);
244
+ }
245
+ }
246
+ }
247
+ const results = [];
248
+ let index = 0;
249
+ for (const reference of references) {
250
+ index += 1;
251
+ try {
252
+ const bytes = await downloadAttachment(reference.url, credential);
253
+ const processed = await processImageBytes(bytes, options.maxWidth);
254
+ const fileName = buildImageFileName(workItemId, index, reference, resizing);
255
+ const outputPath = join(options.outputDir, fileName);
256
+ await writeFile(outputPath, processed.buffer);
257
+ results.push({
258
+ reference,
259
+ path: outputPath,
260
+ resized: processed.resized,
261
+ format: resizing ? "png" : reference.suggestedExtension.replace(/^\./, "")
262
+ });
263
+ } catch (err) {
264
+ results.push({
265
+ reference,
266
+ resized: false,
267
+ format: reference.suggestedExtension.replace(/^\./, ""),
268
+ error: err instanceof Error ? err.message : String(err)
269
+ });
270
+ }
271
+ }
272
+ return results;
273
+ }
274
+ async function runImageDownload(fields, args, credential) {
275
+ const results = await downloadImagesFromFields(fields, args, credential);
276
+ process.stdout.write(formatImageSummary(results) + "\n");
277
+ for (const result of results) {
278
+ if (result.error) {
279
+ process.stderr.write(`Failed to download image ${result.reference.url}: ${result.error}
280
+ `);
281
+ }
282
+ }
283
+ }
284
+ function formatImageSummary(results) {
285
+ if (results.length === 0) {
286
+ return "Images: no images found in rich-text fields";
287
+ }
288
+ const saved = results.filter((r) => r.path);
289
+ const lines = [`Images: ${saved.length} downloaded`];
290
+ for (const result of saved) {
291
+ lines.push(` ${result.path}`);
292
+ }
293
+ return lines.join("\n");
294
+ }
295
+
126
296
  // src/services/azdo-client.ts
127
297
  var DEFAULT_FIELDS = [
128
298
  "System.Title",
@@ -325,12 +495,25 @@ async function getWorkItemFields(context, id, cred) {
325
495
  function extractAttachments(relations) {
326
496
  if (!relations) return null;
327
497
  const attachments = relations.filter((r) => r.rel === "AttachedFile").map((r) => ({
498
+ id: extractAttachmentGuid(r.url) ?? "",
328
499
  name: r.attributes.name ?? "unknown",
329
500
  size: r.attributes.resourceSize ?? 0,
330
501
  url: r.url
331
502
  }));
332
503
  return attachments.length > 0 ? attachments : null;
333
504
  }
505
+ async function findAttachmentRelations(context, id, cred, filename) {
506
+ const data = await fetchWorkItemResponse(context, id, cred, { includeRelations: true });
507
+ const relations = data.relations ?? [];
508
+ return relations.map((r, index) => ({ r, index })).filter(({ r }) => r.rel === "AttachedFile" && r.attributes.name === filename).map(({ r, index }) => ({
509
+ index,
510
+ id: extractAttachmentGuid(r.url) ?? "",
511
+ name: r.attributes.name ?? filename,
512
+ size: r.attributes.resourceSize ?? 0,
513
+ uploadedDate: r.attributes.resourceCreatedDate ?? r.attributes.resourceModifiedDate,
514
+ url: r.url
515
+ }));
516
+ }
334
517
  function buildWorkItemUrl(context, id, options = {}) {
335
518
  const url = new URL(
336
519
  `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
@@ -513,7 +696,7 @@ async function updateWorkItem(context, id, cred, fieldName, operations) {
513
696
  const result = await applyWorkItemPatch(context, id, cred, operations);
514
697
  const title = result.fields["System.Title"];
515
698
  const lastOp = operations.at(-1);
516
- const fieldValue = lastOp?.value ?? null;
699
+ const fieldValue = typeof lastOp?.value === "string" ? lastOp.value : null;
517
700
  return {
518
701
  id: result.id,
519
702
  rev: result.rev,
@@ -553,11 +736,36 @@ async function downloadAttachment(url, cred) {
553
736
  }
554
737
  return response.arrayBuffer();
555
738
  }
739
+ async function createAttachment(context, fileName, content, cred) {
740
+ const url = new URL(
741
+ `https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/attachments`
742
+ );
743
+ url.searchParams.set("fileName", fileName);
744
+ url.searchParams.set("api-version", "7.1");
745
+ const response = await fetchWithErrors(url.toString(), {
746
+ method: "POST",
747
+ headers: {
748
+ ...authHeaders(cred),
749
+ "Content-Type": "application/octet-stream"
750
+ },
751
+ body: new Uint8Array(content)
752
+ });
753
+ if (response.status === 400) {
754
+ const serverMessage = await readResponseMessage(response);
755
+ if (serverMessage) {
756
+ throw new Error(`BAD_REQUEST: ${serverMessage}`);
757
+ }
758
+ }
759
+ if (!response.ok) {
760
+ throw new Error(`HTTP_${response.status}`);
761
+ }
762
+ return await response.json();
763
+ }
556
764
 
557
765
  // src/services/auth.ts
558
766
  import { createInterface } from "readline";
559
- import { existsSync, readFileSync as readFileSync2 } from "fs";
560
- import { dirname as dirname2, join } from "path";
767
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
768
+ import { dirname as dirname2, join as join2 } from "path";
561
769
 
562
770
  // src/services/oauth-device-code.ts
563
771
  var DeviceCodeFlowError = class extends Error {
@@ -728,8 +936,8 @@ async function promptForPat() {
728
936
  function findDotEnvPat(startDir = process.cwd()) {
729
937
  let current = startDir;
730
938
  while (true) {
731
- const envFile = join(current, ".env");
732
- if (existsSync(envFile)) {
939
+ const envFile = join2(current, ".env");
940
+ if (existsSync2(envFile)) {
733
941
  const contents = readFileSync2(envFile, "utf8");
734
942
  for (const line of contents.split("\n")) {
735
943
  const match = line.match(/^AZDO_PAT\s*=([^\n\r]+)$/);
@@ -1339,6 +1547,24 @@ function toMarkdown(content) {
1339
1547
  }
1340
1548
 
1341
1549
  // src/services/command-helpers.ts
1550
+ async function promptYesNo(prompt) {
1551
+ if (!process.stdin.isTTY) return true;
1552
+ process.stderr.write(prompt);
1553
+ return await new Promise((resolve2) => {
1554
+ process.stdin.setEncoding("utf8");
1555
+ let answered = false;
1556
+ const handler = (data) => {
1557
+ if (answered) return;
1558
+ answered = true;
1559
+ process.stdin.removeListener("data", handler);
1560
+ process.stdin.pause();
1561
+ const trimmed = data.trim().toLowerCase();
1562
+ resolve2(trimmed === "y" || trimmed === "yes");
1563
+ };
1564
+ process.stdin.resume();
1565
+ process.stdin.on("data", handler);
1566
+ });
1567
+ }
1342
1568
  function parseWorkItemId(idStr) {
1343
1569
  const id = Number.parseInt(idStr, 10);
1344
1570
  if (!Number.isInteger(id) || id <= 0) {
@@ -1423,173 +1649,6 @@ function handleCommandError(err, id, context, scope = "write", exit = true) {
1423
1649
  }
1424
1650
  }
1425
1651
 
1426
- // src/services/image-download.ts
1427
- import { Jimp } from "jimp";
1428
- import { writeFile } from "fs/promises";
1429
- import { existsSync as existsSync2 } from "fs";
1430
- import { tmpdir } from "os";
1431
- import { join as join2 } from "path";
1432
- 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})/;
1433
- function isAzureDevOpsAttachmentHost(hostname) {
1434
- const host = hostname.toLowerCase();
1435
- return host === "dev.azure.com" || host.endsWith(".dev.azure.com") || host.endsWith(".visualstudio.com");
1436
- }
1437
- function decodeHtmlEntities(value) {
1438
- return value.replaceAll("&quot;", '"').replaceAll("&#39;", "'").replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&amp;", "&");
1439
- }
1440
- function parseAttachmentReference(rawUrl, sourceField) {
1441
- const url = decodeHtmlEntities(rawUrl.trim());
1442
- let parsed;
1443
- try {
1444
- parsed = new URL(url);
1445
- } catch {
1446
- return null;
1447
- }
1448
- if (parsed.protocol !== "https:" || !isAzureDevOpsAttachmentHost(parsed.hostname)) {
1449
- return null;
1450
- }
1451
- const match = ATTACHMENT_GUID_RE.exec(parsed.pathname);
1452
- if (!match) return null;
1453
- const guid = match[1].toLowerCase();
1454
- let suggestedExtension = ".png";
1455
- const fileName = parsed.searchParams.get("fileName");
1456
- if (fileName?.includes(".")) {
1457
- suggestedExtension = fileName.slice(fileName.lastIndexOf(".")).toLowerCase();
1458
- }
1459
- return { url, sourceField, guid, suggestedExtension };
1460
- }
1461
- function extractImageReferences(content, sourceField) {
1462
- if (!content) return [];
1463
- const references = [];
1464
- const seen = /* @__PURE__ */ new Set();
1465
- const add = (rawUrl) => {
1466
- const reference = parseAttachmentReference(rawUrl, sourceField);
1467
- if (reference && !seen.has(reference.guid)) {
1468
- seen.add(reference.guid);
1469
- references.push(reference);
1470
- }
1471
- };
1472
- const imgRegex = /<img\b[^>]*?\ssrc\s*=\s*["']([^"']+)["']/gi;
1473
- let match;
1474
- while ((match = imgRegex.exec(content)) !== null) {
1475
- add(match[1]);
1476
- }
1477
- const markdownRegex = /!\[[^\]]*\]\(\s*([^)\s]+)/g;
1478
- while ((match = markdownRegex.exec(content)) !== null) {
1479
- add(match[1]);
1480
- }
1481
- return references;
1482
- }
1483
- function addImageDownloadOptions(command) {
1484
- 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)");
1485
- }
1486
- function resolveImageDownloadOptionsOrExit(flags) {
1487
- try {
1488
- return resolveImageDownloadOptions(flags);
1489
- } catch (err) {
1490
- process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
1491
- `);
1492
- process.exit(1);
1493
- }
1494
- }
1495
- function resolveImageDownloadOptions(flags) {
1496
- const wantsResize = flags.resizeImages !== void 0;
1497
- const enabled = Boolean(flags.downloadImages) || wantsResize;
1498
- let maxWidth;
1499
- if (wantsResize) {
1500
- const parsed = Number(flags.resizeImages);
1501
- if (!Number.isInteger(parsed) || parsed <= 0) {
1502
- throw new Error(
1503
- `Invalid --resize-images value "${flags.resizeImages}": must be a positive integer (max width in pixels).`
1504
- );
1505
- }
1506
- maxWidth = parsed;
1507
- }
1508
- const outputDir = flags.imagesPath ?? tmpdir();
1509
- if (flags.imagesPath !== void 0 && !existsSync2(outputDir)) {
1510
- throw new Error(`Images path "${outputDir}" does not exist.`);
1511
- }
1512
- return { enabled, maxWidth, outputDir };
1513
- }
1514
- function buildImageFileName(workItemId, index, reference, resizing) {
1515
- const ext = resizing ? ".png" : reference.suggestedExtension;
1516
- return `wi-${workItemId}-${index}${ext}`;
1517
- }
1518
- async function processImageBytes(bytes, maxWidth) {
1519
- if (maxWidth === void 0) {
1520
- return { buffer: Buffer.from(bytes), resized: false, format: "original" };
1521
- }
1522
- const image = await Jimp.read(Buffer.from(bytes));
1523
- let resized = false;
1524
- if (image.bitmap.width > maxWidth) {
1525
- image.resize({ w: maxWidth });
1526
- resized = true;
1527
- }
1528
- const buffer = await image.getBuffer("image/png");
1529
- return { buffer, resized, format: "png" };
1530
- }
1531
- async function downloadImagesFromFields(fields, args, credential) {
1532
- const { workItemId, options } = args;
1533
- const resizing = options.maxWidth !== void 0;
1534
- const seen = /* @__PURE__ */ new Set();
1535
- const references = [];
1536
- for (const field of fields) {
1537
- for (const reference of extractImageReferences(field.content, field.field)) {
1538
- if (!seen.has(reference.guid)) {
1539
- seen.add(reference.guid);
1540
- references.push(reference);
1541
- }
1542
- }
1543
- }
1544
- const results = [];
1545
- let index = 0;
1546
- for (const reference of references) {
1547
- index += 1;
1548
- try {
1549
- const bytes = await downloadAttachment(reference.url, credential);
1550
- const processed = await processImageBytes(bytes, options.maxWidth);
1551
- const fileName = buildImageFileName(workItemId, index, reference, resizing);
1552
- const outputPath = join2(options.outputDir, fileName);
1553
- await writeFile(outputPath, processed.buffer);
1554
- results.push({
1555
- reference,
1556
- path: outputPath,
1557
- resized: processed.resized,
1558
- format: resizing ? "png" : reference.suggestedExtension.replace(/^\./, "")
1559
- });
1560
- } catch (err) {
1561
- results.push({
1562
- reference,
1563
- resized: false,
1564
- format: reference.suggestedExtension.replace(/^\./, ""),
1565
- error: err instanceof Error ? err.message : String(err)
1566
- });
1567
- }
1568
- }
1569
- return results;
1570
- }
1571
- async function runImageDownload(fields, args, credential) {
1572
- const results = await downloadImagesFromFields(fields, args, credential);
1573
- process.stdout.write(formatImageSummary(results) + "\n");
1574
- for (const result of results) {
1575
- if (result.error) {
1576
- process.stderr.write(`Failed to download image ${result.reference.url}: ${result.error}
1577
- `);
1578
- }
1579
- }
1580
- }
1581
- function formatImageSummary(results) {
1582
- if (results.length === 0) {
1583
- return "Images: no images found in rich-text fields";
1584
- }
1585
- const saved = results.filter((r) => r.path);
1586
- const lines = [`Images: ${saved.length} downloaded`];
1587
- for (const result of saved) {
1588
- lines.push(` ${result.path}`);
1589
- }
1590
- return lines.join("\n");
1591
- }
1592
-
1593
1652
  // src/commands/get-item.ts
1594
1653
  function parseRequestedFields(raw) {
1595
1654
  if (raw === void 0) return void 0;
@@ -1885,24 +1944,6 @@ async function readStdinToString() {
1885
1944
  }
1886
1945
  return Buffer.concat(chunks).toString("utf8");
1887
1946
  }
1888
- async function promptYesNo(prompt) {
1889
- if (!process.stdin.isTTY) return true;
1890
- process.stderr.write(prompt);
1891
- return await new Promise((resolve2) => {
1892
- process.stdin.setEncoding("utf8");
1893
- let answered = false;
1894
- const handler = (data) => {
1895
- if (answered) return;
1896
- answered = true;
1897
- process.stdin.removeListener("data", handler);
1898
- process.stdin.pause();
1899
- const trimmed = data.trim().toLowerCase();
1900
- resolve2(trimmed === "y" || trimmed === "yes");
1901
- };
1902
- process.stdin.resume();
1903
- process.stdin.on("data", handler);
1904
- });
1905
- }
1906
1947
  async function confirmOverwrite(org) {
1907
1948
  return promptYesNo(`A PAT is already stored for org ${org}. Overwrite? [y/N] `);
1908
1949
  }
@@ -6085,8 +6126,142 @@ function createDownloadAttachmentCommand() {
6085
6126
  return command;
6086
6127
  }
6087
6128
 
6088
- // src/commands/relations.ts
6129
+ // src/commands/add-attachment.ts
6089
6130
  import { Command as Command16 } from "commander";
6131
+ import { existsSync as existsSync7, statSync } from "fs";
6132
+ import { readFile } from "fs/promises";
6133
+ import { basename } from "path";
6134
+ function createAddAttachmentCommand() {
6135
+ const command = new Command16("add-attachment");
6136
+ command.description("Attach a local file to an Azure DevOps work item").argument("<id>", "work item ID").argument("<file>", "path to the local file to upload").option("--comment <text>", "optional comment to store with the attachment").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").action(
6137
+ async (idStr, file, options) => {
6138
+ const id = parseWorkItemId(idStr);
6139
+ validateOrgProjectPair(options);
6140
+ if (!existsSync7(file)) {
6141
+ process.stderr.write(`Error: File not found: ${file}
6142
+ `);
6143
+ process.exit(1);
6144
+ }
6145
+ if (!statSync(file).isFile()) {
6146
+ process.stderr.write(`Error: "${file}" is not a regular file.
6147
+ `);
6148
+ process.exit(1);
6149
+ }
6150
+ let context;
6151
+ try {
6152
+ const filename = basename(file);
6153
+ const content = await readFile(file);
6154
+ context = resolveContext(options);
6155
+ const credential = await requireAuthCredential(context.org);
6156
+ await getWorkItem(context, id, credential);
6157
+ const attachment = await createAttachment(context, filename, content, credential);
6158
+ await applyWorkItemPatch(context, id, credential, [
6159
+ {
6160
+ op: "add",
6161
+ path: "/relations/-",
6162
+ value: {
6163
+ rel: "AttachedFile",
6164
+ url: attachment.url,
6165
+ ...options.comment ? { attributes: { comment: options.comment } } : {}
6166
+ }
6167
+ }
6168
+ ]);
6169
+ process.stdout.write(
6170
+ `Attached "${filename}" (${formatFileSize(content.length)}) to work item ${id} [id: ${attachment.id}]
6171
+ `
6172
+ );
6173
+ } catch (err) {
6174
+ handleCommandError(err, id, context, "write");
6175
+ }
6176
+ }
6177
+ );
6178
+ return command;
6179
+ }
6180
+
6181
+ // src/commands/delete-attachment.ts
6182
+ import { Command as Command17 } from "commander";
6183
+ function formatUploadDate(iso) {
6184
+ return iso ? iso.slice(0, 10) : "unknown date";
6185
+ }
6186
+ function createDeleteAttachmentCommand() {
6187
+ const command = new Command17("delete-attachment");
6188
+ command.description("Remove an attachment from an Azure DevOps work item").argument("<id>", "work item ID").argument("<filename>", "name of the attachment to remove").option("--id <attachmentId>", "attachment GUID, to disambiguate when the filename is shared by more than one attachment").option("-y, --yes", "skip the interactive confirmation prompt").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").action(
6189
+ async (idStr, filename, options) => {
6190
+ const id = parseWorkItemId(idStr);
6191
+ validateOrgProjectPair(options);
6192
+ let context;
6193
+ try {
6194
+ context = resolveContext(options);
6195
+ const credential = await requireAuthCredential(context.org);
6196
+ const matches = await findAttachmentRelations(context, id, credential, filename);
6197
+ if (matches.length === 0) {
6198
+ process.stderr.write(`Error: Attachment "${filename}" not found on work item ${id}.
6199
+ `);
6200
+ process.exitCode = 1;
6201
+ return;
6202
+ }
6203
+ let target;
6204
+ if (options.id) {
6205
+ const wantedId = options.id.toLowerCase();
6206
+ const narrowed = matches.find((match) => match.id === wantedId);
6207
+ if (!narrowed) {
6208
+ process.stderr.write(
6209
+ `Error: No attachment named "${filename}" with id ${options.id} found on work item ${id}.
6210
+ `
6211
+ );
6212
+ process.exitCode = 1;
6213
+ return;
6214
+ }
6215
+ target = narrowed;
6216
+ } else if (matches.length > 1) {
6217
+ process.stderr.write(
6218
+ `Error: multiple attachments named "${filename}" on work item ${id}:
6219
+ `
6220
+ );
6221
+ for (const match of matches) {
6222
+ process.stderr.write(
6223
+ ` ${match.id} ${formatFileSize(match.size)} ${formatUploadDate(match.uploadedDate)}
6224
+ `
6225
+ );
6226
+ }
6227
+ process.stderr.write("Re-run with --id <guid> to remove a specific one.\n");
6228
+ process.exitCode = 1;
6229
+ return;
6230
+ } else {
6231
+ target = matches[0];
6232
+ }
6233
+ let confirmed = options.yes === true;
6234
+ if (!confirmed) {
6235
+ if (!process.stdin.isTTY) {
6236
+ process.stderr.write(
6237
+ "Error: confirmation required. Re-run with --yes to skip the prompt in a non-interactive shell.\n"
6238
+ );
6239
+ process.exitCode = 1;
6240
+ return;
6241
+ }
6242
+ confirmed = await promptYesNo(`Remove "${filename}" from work item ${id}? [y/N] `);
6243
+ }
6244
+ if (!confirmed) {
6245
+ process.stderr.write("Aborted: attachment not removed.\n");
6246
+ process.exitCode = 1;
6247
+ return;
6248
+ }
6249
+ await applyWorkItemPatch(context, id, credential, [
6250
+ { op: "test", path: `/relations/${target.index}/url`, value: target.url },
6251
+ { op: "remove", path: `/relations/${target.index}` }
6252
+ ]);
6253
+ process.stdout.write(`Removed "${filename}" (id: ${target.id}) from work item ${id}
6254
+ `);
6255
+ } catch (err) {
6256
+ handleCommandError(err, id, context, "write");
6257
+ }
6258
+ }
6259
+ );
6260
+ return command;
6261
+ }
6262
+
6263
+ // src/commands/relations.ts
6264
+ import { Command as Command18 } from "commander";
6090
6265
 
6091
6266
  // src/services/relations-client.ts
6092
6267
  var API_VERSION2 = "7.1";
@@ -6301,7 +6476,7 @@ function parsePositiveInt(value, label) {
6301
6476
  return n;
6302
6477
  }
6303
6478
  function createRelationsCommand() {
6304
- const relations = new Command16("relations").description("Manage work item relations");
6479
+ const relations = new Command18("relations").description("Manage work item relations");
6305
6480
  addCommonOptions(
6306
6481
  relations.command("types").description("List all available work item relation types")
6307
6482
  ).action(async (opts) => {
@@ -6491,7 +6666,7 @@ function exitOnEpipe(err) {
6491
6666
  }
6492
6667
  process.stdout.on("error", exitOnEpipe);
6493
6668
  process.stderr.on("error", exitOnEpipe);
6494
- var program = new Command17();
6669
+ var program = new Command19();
6495
6670
  program.name("azdo").description("Azure DevOps CLI tool").version(version, "-v, --version");
6496
6671
  program.option("--no-update-check", "Skip the check for a newer published version");
6497
6672
  program.option("--trace <filepath>", "Append redacted HTTP request/response trace to a file (owner-read-only permissions)");
@@ -6510,6 +6685,8 @@ program.addCommand(createPrCommand());
6510
6685
  program.addCommand(createPipelineCommand());
6511
6686
  program.addCommand(createCommentsCommand());
6512
6687
  program.addCommand(createDownloadAttachmentCommand());
6688
+ program.addCommand(createAddAttachmentCommand());
6689
+ program.addCommand(createDeleteAttachmentCommand());
6513
6690
  program.addCommand(createRelationsCommand());
6514
6691
  program.showHelpAfterError();
6515
6692
  program.hook("preAction", () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "azdo-cli",
3
- "version": "0.18.0-develop.649",
3
+ "version": "0.18.0-develop.675",
4
4
  "description": "Azure DevOps CLI tool",
5
5
  "type": "module",
6
6
  "bin": {