azdo-cli 0.2.0-develop.8 → 0.2.0-hotfix-0-2-5.63
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/README.md +141 -51
- package/dist/index.js +1025 -2
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -1,79 +1,169 @@
|
|
|
1
|
-
#
|
|
1
|
+
# azdo-cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Azure DevOps CLI focused on work item read/write workflows.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
[](https://www.npmjs.com/package/azdo-cli)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](https://sonarcloud.io/summary/new_code?id=alkampfergit_azdo-cli)
|
|
6
8
|
|
|
7
|
-
|
|
8
|
-
[](https://goreportcard.com/report/github.com/steveyegge/beads)
|
|
9
|
-
[](https://github.com/steveyegge/beads/releases)
|
|
10
|
-
[](https://www.npmjs.com/package/@beads/bd)
|
|
11
|
-
[](https://pypi.org/project/beads-mcp/)
|
|
9
|
+
## Features
|
|
12
10
|
|
|
13
|
-
|
|
11
|
+
- Retrieve work items with readable output (`get-item`)
|
|
12
|
+
- Update work item state (`set-state`)
|
|
13
|
+
- Assign and unassign work items (`assign`)
|
|
14
|
+
- Set any work item field by reference name (`set-field`)
|
|
15
|
+
- Read rich-text fields as markdown (`get-md-field`)
|
|
16
|
+
- Set rich-text fields as markdown from inline text, file, or stdin (`set-md-field`)
|
|
17
|
+
- Persist org/project/default fields in local config (`config`)
|
|
18
|
+
- Store PAT in OS credential store (or use `AZDO_PAT`)
|
|
14
19
|
|
|
15
|
-
##
|
|
20
|
+
## Installation
|
|
16
21
|
|
|
17
22
|
```bash
|
|
18
|
-
|
|
19
|
-
|
|
23
|
+
npm install -g azdo-cli
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Authentication and Context Resolution
|
|
27
|
+
|
|
28
|
+
PAT resolution order:
|
|
29
|
+
1. `AZDO_PAT` environment variable
|
|
30
|
+
2. Stored credential from OS keyring
|
|
31
|
+
3. Interactive PAT prompt (then stored for next runs)
|
|
20
32
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
33
|
+
Org/project resolution order:
|
|
34
|
+
1. `--org` + `--project` flags
|
|
35
|
+
2. Saved config (`azdo config set org ...`, `azdo config set project ...`)
|
|
36
|
+
3. Azure DevOps `origin` git remote auto-detection
|
|
24
37
|
|
|
25
|
-
|
|
26
|
-
|
|
38
|
+
## Quick Start
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# 1) Configure defaults once
|
|
42
|
+
azdo config set org myorg
|
|
43
|
+
azdo config set project myproject
|
|
44
|
+
|
|
45
|
+
# 2) Read a work item
|
|
46
|
+
azdo get-item 12345
|
|
47
|
+
|
|
48
|
+
# 3) Update state
|
|
49
|
+
azdo set-state 12345 "Active"
|
|
27
50
|
```
|
|
28
51
|
|
|
29
|
-
|
|
52
|
+
## Command Cheat Sheet
|
|
30
53
|
|
|
31
|
-
|
|
54
|
+
| Command | Purpose | Common Flags |
|
|
55
|
+
| --- | --- | --- |
|
|
56
|
+
| `azdo get-item <id>` | Read a work item | `--short`, `--fields`, `--org`, `--project` |
|
|
57
|
+
| `azdo set-state <id> <state>` | Change work item state | `--json`, `--org`, `--project` |
|
|
58
|
+
| `azdo assign <id> [name]` | Assign or unassign owner | `--unassign`, `--json`, `--org`, `--project` |
|
|
59
|
+
| `azdo set-field <id> <field> <value>` | Update any field | `--json`, `--org`, `--project` |
|
|
60
|
+
| `azdo get-md-field <id> <field>` | Get field as markdown | `--org`, `--project` |
|
|
61
|
+
| `azdo set-md-field <id> <field> [content]` | Set markdown field | `--file`, `--json`, `--org`, `--project` |
|
|
62
|
+
| `azdo config <subcommand>` | Manage saved settings | `set`, `get`, `list`, `unset`, `wizard`, `--json` |
|
|
63
|
+
| `azdo clear-pat` | Remove stored PAT | none |
|
|
32
64
|
|
|
33
|
-
|
|
34
|
-
* **Agent-Optimized:** JSON output, dependency tracking, and auto-ready task detection.
|
|
35
|
-
* **Zero Conflict:** Hash-based IDs (`bd-a1b2`) prevent merge collisions in multi-agent/multi-branch workflows.
|
|
36
|
-
* **Invisible Infrastructure:** SQLite local cache for speed; background daemon for auto-sync.
|
|
37
|
-
* **Compaction:** Semantic "memory decay" summarizes old closed tasks to save context window.
|
|
65
|
+
## Command Reference
|
|
38
66
|
|
|
39
|
-
|
|
67
|
+
### Core
|
|
40
68
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
69
|
+
```bash
|
|
70
|
+
# Get full work item
|
|
71
|
+
azdo get-item 12345
|
|
72
|
+
|
|
73
|
+
# Get short view
|
|
74
|
+
azdo get-item 12345 --short
|
|
75
|
+
|
|
76
|
+
# Include extra fields for this call
|
|
77
|
+
azdo get-item 12345 --fields "System.Tags,Microsoft.VSTS.Common.Priority"
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
# Set state
|
|
82
|
+
azdo set-state 12345 "Closed"
|
|
83
|
+
|
|
84
|
+
# Assign / unassign
|
|
85
|
+
azdo assign 12345 "someone@company.com"
|
|
86
|
+
azdo assign 12345 --unassign
|
|
87
|
+
|
|
88
|
+
# Set generic field
|
|
89
|
+
azdo set-field 12345 System.Title "Updated title"
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Markdown Field Commands
|
|
48
93
|
|
|
49
|
-
|
|
94
|
+
```bash
|
|
95
|
+
# Read field and auto-convert HTML -> markdown
|
|
96
|
+
azdo get-md-field 12345 System.Description
|
|
97
|
+
|
|
98
|
+
# Set markdown inline
|
|
99
|
+
azdo set-md-field 12345 System.Description "# Title\n\nSome **bold** text"
|
|
100
|
+
|
|
101
|
+
# Set markdown from file
|
|
102
|
+
azdo set-md-field 12345 System.Description --file ./description.md
|
|
103
|
+
|
|
104
|
+
# Set markdown from stdin
|
|
105
|
+
cat description.md | azdo set-md-field 12345 System.Description
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Configuration
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
# List settings
|
|
112
|
+
azdo config list
|
|
113
|
+
|
|
114
|
+
# Interactive setup
|
|
115
|
+
azdo config wizard
|
|
50
116
|
|
|
51
|
-
|
|
117
|
+
# Set/get/unset values
|
|
118
|
+
azdo config set fields "System.Tags,Custom.Priority"
|
|
119
|
+
azdo config get fields
|
|
120
|
+
azdo config unset fields
|
|
52
121
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
122
|
+
# JSON output
|
|
123
|
+
azdo config list --json
|
|
124
|
+
```
|
|
56
125
|
|
|
57
|
-
|
|
126
|
+
### Credential Management
|
|
58
127
|
|
|
59
|
-
|
|
128
|
+
```bash
|
|
129
|
+
# Remove stored PAT from keyring
|
|
130
|
+
azdo clear-pat
|
|
131
|
+
```
|
|
60
132
|
|
|
61
|
-
|
|
62
|
-
* **Maintainers** (write access): Beads auto-detects maintainer role via SSH URLs or HTTPS with credentials. Only need `git config beads.role maintainer` if using GitHub HTTPS without credentials but you have write access.
|
|
133
|
+
## JSON Output
|
|
63
134
|
|
|
64
|
-
|
|
135
|
+
These commands support `--json` for machine-readable output:
|
|
136
|
+
- `set-state`
|
|
137
|
+
- `assign`
|
|
138
|
+
- `set-field`
|
|
139
|
+
- `set-md-field`
|
|
140
|
+
- `config set|get|list|unset`
|
|
65
141
|
|
|
66
|
-
|
|
67
|
-
* **Homebrew:** `brew install beads`
|
|
68
|
-
* **Go:** `go install github.com/steveyegge/beads/cmd/bd@latest`
|
|
142
|
+
## Development
|
|
69
143
|
|
|
70
|
-
|
|
144
|
+
### Prerequisites
|
|
71
145
|
|
|
72
|
-
|
|
146
|
+
- Node.js LTS (20+)
|
|
147
|
+
- npm
|
|
73
148
|
|
|
74
|
-
|
|
149
|
+
### Setup
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
git clone https://github.com/alkampfergit/azdo-cli.git
|
|
153
|
+
cd azdo-cli
|
|
154
|
+
npm install
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Scripts
|
|
158
|
+
|
|
159
|
+
| Command | Description |
|
|
160
|
+
| --- | --- |
|
|
161
|
+
| `npm run build` | Build the CLI with tsup |
|
|
162
|
+
| `npm test` | Build and run tests with vitest |
|
|
163
|
+
| `npm run lint` | Lint source files with ESLint |
|
|
164
|
+
| `npm run typecheck` | Type-check with tsc (no emit) |
|
|
165
|
+
| `npm run format` | Check formatting with Prettier |
|
|
75
166
|
|
|
76
|
-
##
|
|
167
|
+
## License
|
|
77
168
|
|
|
78
|
-
|
|
79
|
-
* [](https://deepwiki.com/steveyegge/beads)
|
|
169
|
+
[MIT](LICENSE)
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { Command } from "commander";
|
|
4
|
+
import { Command as Command9 } from "commander";
|
|
5
5
|
|
|
6
6
|
// src/version.ts
|
|
7
7
|
import { readFileSync } from "fs";
|
|
@@ -11,9 +11,1032 @@ var __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
11
11
|
var pkg = JSON.parse(readFileSync(resolve(__dirname, "../package.json"), "utf-8"));
|
|
12
12
|
var version = pkg.version;
|
|
13
13
|
|
|
14
|
+
// src/commands/get-item.ts
|
|
15
|
+
import { Command } from "commander";
|
|
16
|
+
|
|
17
|
+
// src/services/azdo-client.ts
|
|
18
|
+
var DEFAULT_FIELDS = [
|
|
19
|
+
"System.Title",
|
|
20
|
+
"System.State",
|
|
21
|
+
"System.WorkItemType",
|
|
22
|
+
"System.AssignedTo",
|
|
23
|
+
"System.Description",
|
|
24
|
+
"Microsoft.VSTS.Common.AcceptanceCriteria",
|
|
25
|
+
"Microsoft.VSTS.TCM.ReproSteps",
|
|
26
|
+
"System.AreaPath",
|
|
27
|
+
"System.IterationPath"
|
|
28
|
+
];
|
|
29
|
+
function authHeaders(pat) {
|
|
30
|
+
const token = Buffer.from(`:${pat}`).toString("base64");
|
|
31
|
+
return { Authorization: `Basic ${token}` };
|
|
32
|
+
}
|
|
33
|
+
async function fetchWithErrors(url, init) {
|
|
34
|
+
let response;
|
|
35
|
+
try {
|
|
36
|
+
response = await fetch(url, init);
|
|
37
|
+
} catch {
|
|
38
|
+
throw new Error("NETWORK_ERROR");
|
|
39
|
+
}
|
|
40
|
+
if (response.status === 401) throw new Error("AUTH_FAILED");
|
|
41
|
+
if (response.status === 403) throw new Error("PERMISSION_DENIED");
|
|
42
|
+
if (response.status === 404) throw new Error("NOT_FOUND");
|
|
43
|
+
return response;
|
|
44
|
+
}
|
|
45
|
+
async function readResponseMessage(response) {
|
|
46
|
+
try {
|
|
47
|
+
const body = await response.json();
|
|
48
|
+
if (typeof body.message === "string" && body.message.trim() !== "") {
|
|
49
|
+
return body.message.trim();
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
function normalizeFieldList(fields) {
|
|
56
|
+
return Array.from(new Set(fields.map((f) => f.trim()).filter((f) => f.length > 0)));
|
|
57
|
+
}
|
|
58
|
+
function buildExtraFields(fields, requested) {
|
|
59
|
+
const result = {};
|
|
60
|
+
for (const name of requested) {
|
|
61
|
+
const val = fields[name];
|
|
62
|
+
if (val !== void 0 && val !== null) {
|
|
63
|
+
result[name] = String(val);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return Object.keys(result).length > 0 ? result : null;
|
|
67
|
+
}
|
|
68
|
+
async function getWorkItem(context, id, pat, extraFields) {
|
|
69
|
+
const url = new URL(
|
|
70
|
+
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
|
|
71
|
+
);
|
|
72
|
+
url.searchParams.set("api-version", "7.1");
|
|
73
|
+
const normalizedExtraFields = extraFields ? normalizeFieldList(extraFields) : [];
|
|
74
|
+
if (normalizedExtraFields.length > 0) {
|
|
75
|
+
const allFields = normalizeFieldList([...DEFAULT_FIELDS, ...normalizedExtraFields]);
|
|
76
|
+
url.searchParams.set("fields", allFields.join(","));
|
|
77
|
+
}
|
|
78
|
+
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(pat) });
|
|
79
|
+
if (response.status === 400) {
|
|
80
|
+
const serverMessage = await readResponseMessage(response);
|
|
81
|
+
if (serverMessage) {
|
|
82
|
+
throw new Error(`BAD_REQUEST: ${serverMessage}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
throw new Error(`HTTP_${response.status}`);
|
|
87
|
+
}
|
|
88
|
+
const data = await response.json();
|
|
89
|
+
const descriptionParts = [];
|
|
90
|
+
if (data.fields["System.Description"]) {
|
|
91
|
+
descriptionParts.push({ label: "Description", value: data.fields["System.Description"] });
|
|
92
|
+
}
|
|
93
|
+
if (data.fields["Microsoft.VSTS.Common.AcceptanceCriteria"]) {
|
|
94
|
+
descriptionParts.push({ label: "Acceptance Criteria", value: data.fields["Microsoft.VSTS.Common.AcceptanceCriteria"] });
|
|
95
|
+
}
|
|
96
|
+
if (data.fields["Microsoft.VSTS.TCM.ReproSteps"]) {
|
|
97
|
+
descriptionParts.push({ label: "Repro Steps", value: data.fields["Microsoft.VSTS.TCM.ReproSteps"] });
|
|
98
|
+
}
|
|
99
|
+
let combinedDescription = null;
|
|
100
|
+
if (descriptionParts.length === 1) {
|
|
101
|
+
combinedDescription = descriptionParts[0].value;
|
|
102
|
+
} else if (descriptionParts.length > 1) {
|
|
103
|
+
combinedDescription = descriptionParts.map((p) => `<h3>${p.label}</h3>${p.value}`).join("");
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
id: data.id,
|
|
107
|
+
rev: data.rev,
|
|
108
|
+
title: data.fields["System.Title"],
|
|
109
|
+
state: data.fields["System.State"],
|
|
110
|
+
type: data.fields["System.WorkItemType"],
|
|
111
|
+
assignedTo: data.fields["System.AssignedTo"]?.displayName ?? null,
|
|
112
|
+
description: combinedDescription,
|
|
113
|
+
areaPath: data.fields["System.AreaPath"],
|
|
114
|
+
iterationPath: data.fields["System.IterationPath"],
|
|
115
|
+
url: data._links.html.href,
|
|
116
|
+
extraFields: normalizedExtraFields.length > 0 ? buildExtraFields(data.fields, normalizedExtraFields) : null
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
async function getWorkItemFieldValue(context, id, pat, fieldName) {
|
|
120
|
+
const url = new URL(
|
|
121
|
+
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
|
|
122
|
+
);
|
|
123
|
+
url.searchParams.set("api-version", "7.1");
|
|
124
|
+
url.searchParams.set("fields", fieldName);
|
|
125
|
+
const response = await fetchWithErrors(url.toString(), { headers: authHeaders(pat) });
|
|
126
|
+
if (response.status === 400) {
|
|
127
|
+
const serverMessage = await readResponseMessage(response);
|
|
128
|
+
if (serverMessage) {
|
|
129
|
+
throw new Error(`BAD_REQUEST: ${serverMessage}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (!response.ok) {
|
|
133
|
+
throw new Error(`HTTP_${response.status}`);
|
|
134
|
+
}
|
|
135
|
+
const data = await response.json();
|
|
136
|
+
const value = data.fields[fieldName];
|
|
137
|
+
if (value === void 0 || value === null || value === "") {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
return typeof value === "object" ? JSON.stringify(value) : `${value}`;
|
|
141
|
+
}
|
|
142
|
+
async function updateWorkItem(context, id, pat, fieldName, operations) {
|
|
143
|
+
const url = new URL(
|
|
144
|
+
`https://dev.azure.com/${encodeURIComponent(context.org)}/${encodeURIComponent(context.project)}/_apis/wit/workitems/${id}`
|
|
145
|
+
);
|
|
146
|
+
url.searchParams.set("api-version", "7.1");
|
|
147
|
+
const response = await fetchWithErrors(url.toString(), {
|
|
148
|
+
method: "PATCH",
|
|
149
|
+
headers: {
|
|
150
|
+
...authHeaders(pat),
|
|
151
|
+
"Content-Type": "application/json-patch+json"
|
|
152
|
+
},
|
|
153
|
+
body: JSON.stringify(operations)
|
|
154
|
+
});
|
|
155
|
+
if (response.status === 400) {
|
|
156
|
+
const serverMessage = await readResponseMessage(response) ?? "Unknown error";
|
|
157
|
+
throw new Error(`UPDATE_REJECTED: ${serverMessage}`);
|
|
158
|
+
}
|
|
159
|
+
if (!response.ok) {
|
|
160
|
+
throw new Error(`HTTP_${response.status}`);
|
|
161
|
+
}
|
|
162
|
+
const data = await response.json();
|
|
163
|
+
const lastOp = operations[operations.length - 1];
|
|
164
|
+
const fieldValue = lastOp.value ?? null;
|
|
165
|
+
return {
|
|
166
|
+
id: data.id,
|
|
167
|
+
rev: data.rev,
|
|
168
|
+
title: data.fields["System.Title"],
|
|
169
|
+
fieldName,
|
|
170
|
+
fieldValue
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// src/services/auth.ts
|
|
175
|
+
import { createInterface } from "readline";
|
|
176
|
+
|
|
177
|
+
// src/services/credential-store.ts
|
|
178
|
+
import { Entry } from "@napi-rs/keyring";
|
|
179
|
+
var SERVICE = "azdo-cli";
|
|
180
|
+
var ACCOUNT = "pat";
|
|
181
|
+
async function getPat() {
|
|
182
|
+
try {
|
|
183
|
+
const entry = new Entry(SERVICE, ACCOUNT);
|
|
184
|
+
return entry.getPassword();
|
|
185
|
+
} catch {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
async function storePat(pat) {
|
|
190
|
+
try {
|
|
191
|
+
const entry = new Entry(SERVICE, ACCOUNT);
|
|
192
|
+
entry.setPassword(pat);
|
|
193
|
+
} catch {
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
async function deletePat() {
|
|
197
|
+
try {
|
|
198
|
+
const entry = new Entry(SERVICE, ACCOUNT);
|
|
199
|
+
entry.deletePassword();
|
|
200
|
+
return true;
|
|
201
|
+
} catch {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// src/services/auth.ts
|
|
207
|
+
function normalizePat(rawPat) {
|
|
208
|
+
const trimmedPat = rawPat.trim();
|
|
209
|
+
return trimmedPat.length > 0 ? trimmedPat : null;
|
|
210
|
+
}
|
|
211
|
+
async function promptForPat() {
|
|
212
|
+
if (!process.stdin.isTTY) {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
return new Promise((resolve2) => {
|
|
216
|
+
const rl = createInterface({
|
|
217
|
+
input: process.stdin,
|
|
218
|
+
output: process.stderr
|
|
219
|
+
});
|
|
220
|
+
process.stderr.write("Enter your Azure DevOps PAT: ");
|
|
221
|
+
process.stdin.setRawMode(true);
|
|
222
|
+
process.stdin.resume();
|
|
223
|
+
let pat = "";
|
|
224
|
+
const onData = (key) => {
|
|
225
|
+
const ch = key.toString("utf8");
|
|
226
|
+
if (ch === "") {
|
|
227
|
+
process.stdin.setRawMode(false);
|
|
228
|
+
process.stdin.removeListener("data", onData);
|
|
229
|
+
rl.close();
|
|
230
|
+
process.stderr.write("\n");
|
|
231
|
+
resolve2(null);
|
|
232
|
+
} else if (ch === "\r" || ch === "\n") {
|
|
233
|
+
process.stdin.setRawMode(false);
|
|
234
|
+
process.stdin.removeListener("data", onData);
|
|
235
|
+
rl.close();
|
|
236
|
+
process.stderr.write("\n");
|
|
237
|
+
resolve2(pat);
|
|
238
|
+
} else if (ch === "\x7F" || ch === "\b") {
|
|
239
|
+
if (pat.length > 0) {
|
|
240
|
+
pat = pat.slice(0, -1);
|
|
241
|
+
process.stderr.write("\b \b");
|
|
242
|
+
}
|
|
243
|
+
} else {
|
|
244
|
+
pat += ch;
|
|
245
|
+
process.stderr.write("*".repeat(ch.length));
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
process.stdin.on("data", onData);
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
async function resolvePat() {
|
|
252
|
+
const envPat = process.env.AZDO_PAT;
|
|
253
|
+
if (envPat) {
|
|
254
|
+
return { pat: envPat, source: "env" };
|
|
255
|
+
}
|
|
256
|
+
const storedPat = await getPat();
|
|
257
|
+
if (storedPat !== null) {
|
|
258
|
+
return { pat: storedPat, source: "credential-store" };
|
|
259
|
+
}
|
|
260
|
+
const promptedPat = await promptForPat();
|
|
261
|
+
if (promptedPat !== null) {
|
|
262
|
+
const normalizedPat = normalizePat(promptedPat);
|
|
263
|
+
if (normalizedPat !== null) {
|
|
264
|
+
await storePat(normalizedPat);
|
|
265
|
+
return { pat: normalizedPat, source: "prompt" };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
throw new Error(
|
|
269
|
+
"Authentication cancelled. Set AZDO_PAT environment variable or run again to enter a PAT."
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// src/services/git-remote.ts
|
|
274
|
+
import { execSync } from "child_process";
|
|
275
|
+
var patterns = [
|
|
276
|
+
// HTTPS (current): https://dev.azure.com/{org}/{project}/_git/{repo}
|
|
277
|
+
/^https?:\/\/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/.+$/,
|
|
278
|
+
// HTTPS (legacy + DefaultCollection): https://{org}.visualstudio.com/DefaultCollection/{project}/_git/{repo}
|
|
279
|
+
/^https?:\/\/([^.]+)\.visualstudio\.com\/DefaultCollection\/([^/]+)\/_git\/.+$/,
|
|
280
|
+
// HTTPS (legacy): https://{org}.visualstudio.com/{project}/_git/{repo}
|
|
281
|
+
/^https?:\/\/([^.]+)\.visualstudio\.com\/([^/]+)\/_git\/.+$/,
|
|
282
|
+
// SSH (current): git@ssh.dev.azure.com:v3/{org}/{project}/{repo}
|
|
283
|
+
/^git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/.+$/,
|
|
284
|
+
// SSH (legacy): {org}@vs-ssh.visualstudio.com:v3/{org}/{project}/{repo}
|
|
285
|
+
/^[^@]+@vs-ssh\.visualstudio\.com:v3\/([^/]+)\/([^/]+)\/.+$/
|
|
286
|
+
];
|
|
287
|
+
function parseAzdoRemote(url) {
|
|
288
|
+
for (const pattern of patterns) {
|
|
289
|
+
const match = url.match(pattern);
|
|
290
|
+
if (match) {
|
|
291
|
+
const project = match[2];
|
|
292
|
+
if (/^DefaultCollection$/i.test(project)) {
|
|
293
|
+
return { org: match[1], project: "" };
|
|
294
|
+
}
|
|
295
|
+
return { org: match[1], project };
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
function detectAzdoContext() {
|
|
301
|
+
let remoteUrl;
|
|
302
|
+
try {
|
|
303
|
+
remoteUrl = execSync("git remote get-url origin", { encoding: "utf-8" }).trim();
|
|
304
|
+
} catch {
|
|
305
|
+
throw new Error("Not in a git repository. Provide --org and --project explicitly.");
|
|
306
|
+
}
|
|
307
|
+
const context = parseAzdoRemote(remoteUrl);
|
|
308
|
+
if (!context || !context.org && !context.project) {
|
|
309
|
+
throw new Error('Git remote "origin" is not an Azure DevOps URL. Provide --org and --project explicitly.');
|
|
310
|
+
}
|
|
311
|
+
return context;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// src/services/config-store.ts
|
|
315
|
+
import fs from "fs";
|
|
316
|
+
import path from "path";
|
|
317
|
+
import os from "os";
|
|
318
|
+
var SETTINGS = [
|
|
319
|
+
{
|
|
320
|
+
key: "org",
|
|
321
|
+
description: "Azure DevOps organization name",
|
|
322
|
+
type: "string",
|
|
323
|
+
example: "mycompany",
|
|
324
|
+
required: true
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
key: "project",
|
|
328
|
+
description: "Azure DevOps project name",
|
|
329
|
+
type: "string",
|
|
330
|
+
example: "MyProject",
|
|
331
|
+
required: true
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
key: "fields",
|
|
335
|
+
description: "Extra work item fields to include (comma-separated reference names)",
|
|
336
|
+
type: "string[]",
|
|
337
|
+
example: "System.Tags,Custom.Priority",
|
|
338
|
+
required: false
|
|
339
|
+
}
|
|
340
|
+
];
|
|
341
|
+
var VALID_KEYS = SETTINGS.map((s) => s.key);
|
|
342
|
+
function getConfigPath() {
|
|
343
|
+
return path.join(os.homedir(), ".azdo", "config.json");
|
|
344
|
+
}
|
|
345
|
+
function loadConfig() {
|
|
346
|
+
const configPath = getConfigPath();
|
|
347
|
+
let raw;
|
|
348
|
+
try {
|
|
349
|
+
raw = fs.readFileSync(configPath, "utf-8");
|
|
350
|
+
} catch (err) {
|
|
351
|
+
if (err.code === "ENOENT") {
|
|
352
|
+
return {};
|
|
353
|
+
}
|
|
354
|
+
throw err;
|
|
355
|
+
}
|
|
356
|
+
try {
|
|
357
|
+
return JSON.parse(raw);
|
|
358
|
+
} catch {
|
|
359
|
+
process.stderr.write(`Warning: Config file ${configPath} contains invalid JSON. Using defaults.
|
|
360
|
+
`);
|
|
361
|
+
return {};
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
function saveConfig(config) {
|
|
365
|
+
const configPath = getConfigPath();
|
|
366
|
+
const dir = path.dirname(configPath);
|
|
367
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
368
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
369
|
+
}
|
|
370
|
+
function validateKey(key) {
|
|
371
|
+
if (!VALID_KEYS.includes(key)) {
|
|
372
|
+
throw new Error(`Unknown setting key "${key}". Valid keys: org, project, fields`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
function getConfigValue(key) {
|
|
376
|
+
validateKey(key);
|
|
377
|
+
const config = loadConfig();
|
|
378
|
+
return config[key];
|
|
379
|
+
}
|
|
380
|
+
function setConfigValue(key, value) {
|
|
381
|
+
validateKey(key);
|
|
382
|
+
const config = loadConfig();
|
|
383
|
+
if (value === "") {
|
|
384
|
+
delete config[key];
|
|
385
|
+
} else if (key === "fields") {
|
|
386
|
+
config.fields = value.split(",").map((s) => s.trim());
|
|
387
|
+
} else {
|
|
388
|
+
config[key] = value;
|
|
389
|
+
}
|
|
390
|
+
saveConfig(config);
|
|
391
|
+
}
|
|
392
|
+
function unsetConfigValue(key) {
|
|
393
|
+
validateKey(key);
|
|
394
|
+
const config = loadConfig();
|
|
395
|
+
delete config[key];
|
|
396
|
+
saveConfig(config);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// src/services/context.ts
|
|
400
|
+
function resolveContext(options) {
|
|
401
|
+
if (options.org && options.project) {
|
|
402
|
+
return { org: options.org, project: options.project };
|
|
403
|
+
}
|
|
404
|
+
const config = loadConfig();
|
|
405
|
+
if (config.org && config.project) {
|
|
406
|
+
return { org: config.org, project: config.project };
|
|
407
|
+
}
|
|
408
|
+
let gitContext = null;
|
|
409
|
+
try {
|
|
410
|
+
gitContext = detectAzdoContext();
|
|
411
|
+
} catch {
|
|
412
|
+
}
|
|
413
|
+
const org = config.org || gitContext?.org;
|
|
414
|
+
const project = config.project || gitContext?.project;
|
|
415
|
+
if (org && project) {
|
|
416
|
+
return { org, project };
|
|
417
|
+
}
|
|
418
|
+
throw new Error(
|
|
419
|
+
'Could not determine org/project. Use --org and --project flags, work from an Azure DevOps git repo, or run "azdo config set org/project".'
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// src/services/command-helpers.ts
|
|
424
|
+
function parseWorkItemId(idStr) {
|
|
425
|
+
const id = Number.parseInt(idStr, 10);
|
|
426
|
+
if (!Number.isInteger(id) || id <= 0) {
|
|
427
|
+
process.stderr.write(
|
|
428
|
+
`Error: Work item ID must be a positive integer. Got: "${idStr}"
|
|
429
|
+
`
|
|
430
|
+
);
|
|
431
|
+
process.exit(1);
|
|
432
|
+
}
|
|
433
|
+
return id;
|
|
434
|
+
}
|
|
435
|
+
function validateOrgProjectPair(options) {
|
|
436
|
+
const hasOrg = options.org !== void 0;
|
|
437
|
+
const hasProject = options.project !== void 0;
|
|
438
|
+
if (hasOrg !== hasProject) {
|
|
439
|
+
process.stderr.write(
|
|
440
|
+
"Error: --org and --project must both be provided, or both omitted.\n"
|
|
441
|
+
);
|
|
442
|
+
process.exit(1);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
function handleCommandError(err, id, context, scope = "write", exit = true) {
|
|
446
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
447
|
+
const msg = error.message;
|
|
448
|
+
const scopeLabel = scope === "read" ? "Work Items (read)" : "Work Items (Read & Write)";
|
|
449
|
+
if (msg === "AUTH_FAILED") {
|
|
450
|
+
process.stderr.write(
|
|
451
|
+
`Error: Authentication failed. Check that your PAT is valid and has the "${scopeLabel}" scope.
|
|
452
|
+
`
|
|
453
|
+
);
|
|
454
|
+
} else if (msg === "PERMISSION_DENIED") {
|
|
455
|
+
process.stderr.write(
|
|
456
|
+
`Error: Access denied. Your PAT may lack ${scope} permissions for project "${context?.project}".
|
|
457
|
+
`
|
|
458
|
+
);
|
|
459
|
+
} else if (msg === "NOT_FOUND") {
|
|
460
|
+
process.stderr.write(
|
|
461
|
+
`Error: Work item ${id} not found in ${context?.org}/${context?.project}.
|
|
462
|
+
`
|
|
463
|
+
);
|
|
464
|
+
} else if (msg === "NETWORK_ERROR") {
|
|
465
|
+
process.stderr.write(
|
|
466
|
+
"Error: Could not connect to Azure DevOps. Check your network connection.\n"
|
|
467
|
+
);
|
|
468
|
+
} else if (msg.startsWith("BAD_REQUEST:")) {
|
|
469
|
+
const serverMsg = msg.replace("BAD_REQUEST: ", "");
|
|
470
|
+
process.stderr.write(`Error: Request rejected: ${serverMsg}
|
|
471
|
+
`);
|
|
472
|
+
} else if (msg.startsWith("UPDATE_REJECTED:")) {
|
|
473
|
+
const serverMsg = msg.replace("UPDATE_REJECTED: ", "");
|
|
474
|
+
process.stderr.write(`Error: Update rejected: ${serverMsg}
|
|
475
|
+
`);
|
|
476
|
+
} else {
|
|
477
|
+
process.stderr.write(`Error: ${msg}
|
|
478
|
+
`);
|
|
479
|
+
}
|
|
480
|
+
if (exit) {
|
|
481
|
+
process.exit(1);
|
|
482
|
+
} else {
|
|
483
|
+
process.exitCode = 1;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// src/commands/get-item.ts
|
|
488
|
+
function parseRequestedFields(raw) {
|
|
489
|
+
if (raw === void 0) return void 0;
|
|
490
|
+
const source = Array.isArray(raw) ? raw : [raw];
|
|
491
|
+
const tokens = source.flatMap((entry) => entry.split(/[,\s]+/)).map((field) => field.trim()).filter((field) => field.length > 0);
|
|
492
|
+
if (tokens.length === 0) return void 0;
|
|
493
|
+
return Array.from(new Set(tokens));
|
|
494
|
+
}
|
|
495
|
+
function stripHtml(html) {
|
|
496
|
+
let text = html;
|
|
497
|
+
text = text.replace(/<h[1-6][^>]*>(.*?)<\/h[1-6]>/gi, "\n--- $1 ---\n");
|
|
498
|
+
text = text.replace(/<br\s*\/?>/gi, "\n");
|
|
499
|
+
text = text.replace(/<\/?(p|div)>/gi, "\n");
|
|
500
|
+
text = text.replace(/<li>/gi, "\n");
|
|
501
|
+
text = text.replace(/<[^>]*>/g, "");
|
|
502
|
+
text = text.replace(/&/g, "&");
|
|
503
|
+
text = text.replace(/</g, "<");
|
|
504
|
+
text = text.replace(/>/g, ">");
|
|
505
|
+
text = text.replace(/"/g, '"');
|
|
506
|
+
text = text.replace(/'/g, "'");
|
|
507
|
+
text = text.replace(/ /g, " ");
|
|
508
|
+
text = text.replace(/\n{3,}/g, "\n\n");
|
|
509
|
+
return text.trim();
|
|
510
|
+
}
|
|
511
|
+
function formatWorkItem(workItem, short) {
|
|
512
|
+
const lines = [];
|
|
513
|
+
const label = (name) => name.padEnd(13);
|
|
514
|
+
lines.push(`${label("ID:")}${workItem.id}`);
|
|
515
|
+
lines.push(`${label("Type:")}${workItem.type}`);
|
|
516
|
+
lines.push(`${label("Title:")}${workItem.title}`);
|
|
517
|
+
lines.push(`${label("State:")}${workItem.state}`);
|
|
518
|
+
lines.push(`${label("Assigned To:")}${workItem.assignedTo ?? "Unassigned"}`);
|
|
519
|
+
if (!short) {
|
|
520
|
+
lines.push(`${label("Area:")}${workItem.areaPath}`);
|
|
521
|
+
lines.push(`${label("Iteration:")}${workItem.iterationPath}`);
|
|
522
|
+
}
|
|
523
|
+
lines.push(`${label("URL:")}${workItem.url}`);
|
|
524
|
+
if (workItem.extraFields) {
|
|
525
|
+
for (const [refName, value] of Object.entries(workItem.extraFields)) {
|
|
526
|
+
const fieldLabel = refName.includes(".") ? refName.split(".").pop() : refName;
|
|
527
|
+
lines.push(`${fieldLabel.padEnd(13)}${value}`);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
lines.push("");
|
|
531
|
+
const descriptionText = workItem.description ? stripHtml(workItem.description) : "";
|
|
532
|
+
if (short) {
|
|
533
|
+
const descLines = descriptionText.split("\n").filter((l) => l.trim() !== "");
|
|
534
|
+
const firstThree = descLines.slice(0, 3);
|
|
535
|
+
const truncated = descLines.length > 3;
|
|
536
|
+
const descSummary = firstThree.join("\n") + (truncated ? "\n..." : "");
|
|
537
|
+
lines.push(`${label("Description:")}${descSummary}`);
|
|
538
|
+
} else {
|
|
539
|
+
lines.push("Description:");
|
|
540
|
+
lines.push(descriptionText);
|
|
541
|
+
}
|
|
542
|
+
return lines.join("\n");
|
|
543
|
+
}
|
|
544
|
+
function createGetItemCommand() {
|
|
545
|
+
const command = new Command("get-item");
|
|
546
|
+
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").action(
|
|
547
|
+
async (idStr, options) => {
|
|
548
|
+
const id = parseWorkItemId(idStr);
|
|
549
|
+
validateOrgProjectPair(options);
|
|
550
|
+
let context;
|
|
551
|
+
try {
|
|
552
|
+
context = resolveContext(options);
|
|
553
|
+
const credential = await resolvePat();
|
|
554
|
+
const fieldsList = options.fields !== void 0 ? parseRequestedFields(options.fields) : parseRequestedFields(loadConfig().fields);
|
|
555
|
+
const workItem = await getWorkItem(context, id, credential.pat, fieldsList);
|
|
556
|
+
const output = formatWorkItem(workItem, options.short ?? false);
|
|
557
|
+
process.stdout.write(output + "\n");
|
|
558
|
+
} catch (err) {
|
|
559
|
+
handleCommandError(err, id, context, "read", false);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
);
|
|
563
|
+
return command;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// src/commands/clear-pat.ts
|
|
567
|
+
import { Command as Command2 } from "commander";
|
|
568
|
+
function createClearPatCommand() {
|
|
569
|
+
const command = new Command2("clear-pat");
|
|
570
|
+
command.description("Remove the stored Azure DevOps PAT from the credential store").action(async () => {
|
|
571
|
+
const deleted = await deletePat();
|
|
572
|
+
if (deleted) {
|
|
573
|
+
process.stdout.write("PAT removed from credential store.\n");
|
|
574
|
+
} else {
|
|
575
|
+
process.stdout.write("No stored PAT found.\n");
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
return command;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// src/commands/config.ts
|
|
582
|
+
import { Command as Command3 } from "commander";
|
|
583
|
+
import { createInterface as createInterface2 } from "readline";
|
|
584
|
+
function createConfigCommand() {
|
|
585
|
+
const config = new Command3("config");
|
|
586
|
+
config.description("Manage CLI settings");
|
|
587
|
+
const set = new Command3("set");
|
|
588
|
+
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) => {
|
|
589
|
+
try {
|
|
590
|
+
setConfigValue(key, value);
|
|
591
|
+
if (options.json) {
|
|
592
|
+
const output = { key, value };
|
|
593
|
+
if (key === "fields") {
|
|
594
|
+
output.value = value.split(",").map((s) => s.trim());
|
|
595
|
+
}
|
|
596
|
+
process.stdout.write(JSON.stringify(output) + "\n");
|
|
597
|
+
} else {
|
|
598
|
+
process.stdout.write(`Set "${key}" to "${value}"
|
|
599
|
+
`);
|
|
600
|
+
}
|
|
601
|
+
} catch (err) {
|
|
602
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
603
|
+
process.stderr.write(`Error: ${message}
|
|
604
|
+
`);
|
|
605
|
+
process.exit(1);
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
const get = new Command3("get");
|
|
609
|
+
get.description("Get a configuration value").argument("<key>", "setting key (org, project, fields)").option("--json", "output in JSON format").action((key, options) => {
|
|
610
|
+
try {
|
|
611
|
+
const value = getConfigValue(key);
|
|
612
|
+
if (options.json) {
|
|
613
|
+
process.stdout.write(
|
|
614
|
+
JSON.stringify({ key, value: value ?? null }) + "\n"
|
|
615
|
+
);
|
|
616
|
+
} else if (value === void 0) {
|
|
617
|
+
process.stdout.write(`Setting "${key}" is not configured.
|
|
618
|
+
`);
|
|
619
|
+
} else if (Array.isArray(value)) {
|
|
620
|
+
process.stdout.write(value.join(",") + "\n");
|
|
621
|
+
} else {
|
|
622
|
+
process.stdout.write(value + "\n");
|
|
623
|
+
}
|
|
624
|
+
} catch (err) {
|
|
625
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
626
|
+
process.stderr.write(`Error: ${message}
|
|
627
|
+
`);
|
|
628
|
+
process.exit(1);
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
const list = new Command3("list");
|
|
632
|
+
list.description("List all configuration values").option("--json", "output in JSON format").action((options) => {
|
|
633
|
+
const cfg = loadConfig();
|
|
634
|
+
if (options.json) {
|
|
635
|
+
process.stdout.write(JSON.stringify(cfg) + "\n");
|
|
636
|
+
} else {
|
|
637
|
+
const keyWidth = 10;
|
|
638
|
+
const valueWidth = 30;
|
|
639
|
+
for (const setting of SETTINGS) {
|
|
640
|
+
const raw = cfg[setting.key];
|
|
641
|
+
const value = raw === void 0 ? "(not set)" : Array.isArray(raw) ? raw.join(",") : raw;
|
|
642
|
+
const marker = raw === void 0 && setting.required ? " *" : "";
|
|
643
|
+
process.stdout.write(
|
|
644
|
+
`${setting.key.padEnd(keyWidth)}${String(value).padEnd(valueWidth)}${setting.description}${marker}
|
|
645
|
+
`
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
const hasUnset = SETTINGS.some(
|
|
649
|
+
(s) => s.required && cfg[s.key] === void 0
|
|
650
|
+
);
|
|
651
|
+
if (hasUnset) {
|
|
652
|
+
process.stdout.write(
|
|
653
|
+
'\n* = required but not configured. Run "azdo config wizard" to set up.\n'
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
const unset = new Command3("unset");
|
|
659
|
+
unset.description("Remove a configuration value").argument("<key>", "setting key (org, project, fields)").option("--json", "output in JSON format").action((key, options) => {
|
|
660
|
+
try {
|
|
661
|
+
unsetConfigValue(key);
|
|
662
|
+
if (options.json) {
|
|
663
|
+
process.stdout.write(JSON.stringify({ key, unset: true }) + "\n");
|
|
664
|
+
} else {
|
|
665
|
+
process.stdout.write(`Unset "${key}"
|
|
666
|
+
`);
|
|
667
|
+
}
|
|
668
|
+
} catch (err) {
|
|
669
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
670
|
+
process.stderr.write(`Error: ${message}
|
|
671
|
+
`);
|
|
672
|
+
process.exit(1);
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
const wizard = new Command3("wizard");
|
|
676
|
+
wizard.description("Interactive wizard to configure all settings").action(async () => {
|
|
677
|
+
if (!process.stdin.isTTY) {
|
|
678
|
+
process.stderr.write(
|
|
679
|
+
"Error: Wizard requires an interactive terminal.\n"
|
|
680
|
+
);
|
|
681
|
+
process.exit(1);
|
|
682
|
+
}
|
|
683
|
+
const cfg = loadConfig();
|
|
684
|
+
const rl = createInterface2({
|
|
685
|
+
input: process.stdin,
|
|
686
|
+
output: process.stderr
|
|
687
|
+
});
|
|
688
|
+
const ask = (prompt) => new Promise((resolve2) => rl.question(prompt, resolve2));
|
|
689
|
+
process.stderr.write("Azure DevOps CLI - Configuration Wizard\n");
|
|
690
|
+
process.stderr.write("=======================================\n\n");
|
|
691
|
+
for (const setting of SETTINGS) {
|
|
692
|
+
const current = cfg[setting.key];
|
|
693
|
+
const currentDisplay = current === void 0 ? "" : Array.isArray(current) ? current.join(",") : current;
|
|
694
|
+
const requiredTag = setting.required ? " (required)" : " (optional)";
|
|
695
|
+
process.stderr.write(`${setting.description}${requiredTag}
|
|
696
|
+
`);
|
|
697
|
+
if (setting.example) {
|
|
698
|
+
process.stderr.write(` Example: ${setting.example}
|
|
699
|
+
`);
|
|
700
|
+
}
|
|
701
|
+
const defaultHint = currentDisplay ? ` [${currentDisplay}]` : "";
|
|
702
|
+
const answer = await ask(` ${setting.key}${defaultHint}: `);
|
|
703
|
+
const trimmed = answer.trim();
|
|
704
|
+
if (trimmed) {
|
|
705
|
+
setConfigValue(setting.key, trimmed);
|
|
706
|
+
process.stderr.write(` -> Set "${setting.key}" to "${trimmed}"
|
|
707
|
+
|
|
708
|
+
`);
|
|
709
|
+
} else if (currentDisplay) {
|
|
710
|
+
process.stderr.write(` -> Kept "${setting.key}" as "${currentDisplay}"
|
|
711
|
+
|
|
712
|
+
`);
|
|
713
|
+
} else {
|
|
714
|
+
process.stderr.write(` -> Skipped "${setting.key}"
|
|
715
|
+
|
|
716
|
+
`);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
rl.close();
|
|
720
|
+
process.stderr.write("Configuration complete!\n");
|
|
721
|
+
});
|
|
722
|
+
config.addCommand(set);
|
|
723
|
+
config.addCommand(get);
|
|
724
|
+
config.addCommand(list);
|
|
725
|
+
config.addCommand(unset);
|
|
726
|
+
config.addCommand(wizard);
|
|
727
|
+
return config;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// src/commands/set-state.ts
|
|
731
|
+
import { Command as Command4 } from "commander";
|
|
732
|
+
function createSetStateCommand() {
|
|
733
|
+
const command = new Command4("set-state");
|
|
734
|
+
command.description("Change the state of a work item").argument("<id>", "work item ID").argument("<state>", 'target state (e.g., "Active", "Closed")').option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output result as JSON").action(
|
|
735
|
+
async (idStr, state, options) => {
|
|
736
|
+
const id = parseWorkItemId(idStr);
|
|
737
|
+
validateOrgProjectPair(options);
|
|
738
|
+
let context;
|
|
739
|
+
try {
|
|
740
|
+
context = resolveContext(options);
|
|
741
|
+
const credential = await resolvePat();
|
|
742
|
+
const operations = [
|
|
743
|
+
{ op: "add", path: "/fields/System.State", value: state }
|
|
744
|
+
];
|
|
745
|
+
const result = await updateWorkItem(context, id, credential.pat, "System.State", operations);
|
|
746
|
+
if (options.json) {
|
|
747
|
+
process.stdout.write(
|
|
748
|
+
JSON.stringify({
|
|
749
|
+
id: result.id,
|
|
750
|
+
rev: result.rev,
|
|
751
|
+
title: result.title,
|
|
752
|
+
field: result.fieldName,
|
|
753
|
+
value: result.fieldValue
|
|
754
|
+
}) + "\n"
|
|
755
|
+
);
|
|
756
|
+
} else {
|
|
757
|
+
process.stdout.write(`Updated work item ${result.id}: State -> ${state}
|
|
758
|
+
`);
|
|
759
|
+
}
|
|
760
|
+
} catch (err) {
|
|
761
|
+
handleCommandError(err, id, context, "write");
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
);
|
|
765
|
+
return command;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// src/commands/assign.ts
|
|
769
|
+
import { Command as Command5 } from "commander";
|
|
770
|
+
function createAssignCommand() {
|
|
771
|
+
const command = new Command5("assign");
|
|
772
|
+
command.description("Assign a work item to a user, or unassign it").argument("<id>", "work item ID").argument("[name]", "user display name or email").option("--unassign", "clear the Assigned To field").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output result as JSON").action(
|
|
773
|
+
async (idStr, name, options) => {
|
|
774
|
+
const id = parseWorkItemId(idStr);
|
|
775
|
+
if (!name && !options.unassign) {
|
|
776
|
+
process.stderr.write(
|
|
777
|
+
"Error: Either provide a user name or use --unassign.\n"
|
|
778
|
+
);
|
|
779
|
+
process.exit(1);
|
|
780
|
+
}
|
|
781
|
+
if (name && options.unassign) {
|
|
782
|
+
process.stderr.write(
|
|
783
|
+
"Error: Cannot provide both a user name and --unassign.\n"
|
|
784
|
+
);
|
|
785
|
+
process.exit(1);
|
|
786
|
+
}
|
|
787
|
+
validateOrgProjectPair(options);
|
|
788
|
+
let context;
|
|
789
|
+
try {
|
|
790
|
+
context = resolveContext(options);
|
|
791
|
+
const credential = await resolvePat();
|
|
792
|
+
const value = options.unassign ? "" : name;
|
|
793
|
+
const operations = [
|
|
794
|
+
{ op: "add", path: "/fields/System.AssignedTo", value }
|
|
795
|
+
];
|
|
796
|
+
const result = await updateWorkItem(context, id, credential.pat, "System.AssignedTo", operations);
|
|
797
|
+
if (options.json) {
|
|
798
|
+
process.stdout.write(
|
|
799
|
+
JSON.stringify({
|
|
800
|
+
id: result.id,
|
|
801
|
+
rev: result.rev,
|
|
802
|
+
title: result.title,
|
|
803
|
+
field: result.fieldName,
|
|
804
|
+
value: result.fieldValue
|
|
805
|
+
}) + "\n"
|
|
806
|
+
);
|
|
807
|
+
} else {
|
|
808
|
+
const displayValue = options.unassign ? "(unassigned)" : name;
|
|
809
|
+
process.stdout.write(`Updated work item ${result.id}: Assigned To -> ${displayValue}
|
|
810
|
+
`);
|
|
811
|
+
}
|
|
812
|
+
} catch (err) {
|
|
813
|
+
handleCommandError(err, id, context, "write");
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
);
|
|
817
|
+
return command;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// src/commands/set-field.ts
|
|
821
|
+
import { Command as Command6 } from "commander";
|
|
822
|
+
function createSetFieldCommand() {
|
|
823
|
+
const command = new Command6("set-field");
|
|
824
|
+
command.description("Set any work item field by its reference name").argument("<id>", "work item ID").argument("<field>", "field reference name (e.g., System.Title)").argument("<value>", "new value for the field").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output result as JSON").action(
|
|
825
|
+
async (idStr, field, value, options) => {
|
|
826
|
+
const id = parseWorkItemId(idStr);
|
|
827
|
+
validateOrgProjectPair(options);
|
|
828
|
+
let context;
|
|
829
|
+
try {
|
|
830
|
+
context = resolveContext(options);
|
|
831
|
+
const credential = await resolvePat();
|
|
832
|
+
const operations = [
|
|
833
|
+
{ op: "add", path: `/fields/${field}`, value }
|
|
834
|
+
];
|
|
835
|
+
const result = await updateWorkItem(context, id, credential.pat, field, operations);
|
|
836
|
+
if (options.json) {
|
|
837
|
+
process.stdout.write(
|
|
838
|
+
JSON.stringify({
|
|
839
|
+
id: result.id,
|
|
840
|
+
rev: result.rev,
|
|
841
|
+
title: result.title,
|
|
842
|
+
field: result.fieldName,
|
|
843
|
+
value: result.fieldValue
|
|
844
|
+
}) + "\n"
|
|
845
|
+
);
|
|
846
|
+
} else {
|
|
847
|
+
process.stdout.write(`Updated work item ${result.id}: ${field} -> ${value}
|
|
848
|
+
`);
|
|
849
|
+
}
|
|
850
|
+
} catch (err) {
|
|
851
|
+
handleCommandError(err, id, context, "write");
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
);
|
|
855
|
+
return command;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// src/commands/get-md-field.ts
|
|
859
|
+
import { Command as Command7 } from "commander";
|
|
860
|
+
|
|
861
|
+
// src/services/md-convert.ts
|
|
862
|
+
import { NodeHtmlMarkdown } from "node-html-markdown";
|
|
863
|
+
|
|
864
|
+
// src/services/html-detect.ts
|
|
865
|
+
var HTML_TAG_REGEX = /<\/?([a-z][a-z0-9]*)\b/gi;
|
|
866
|
+
var HTML_TAGS = /* @__PURE__ */ new Set([
|
|
867
|
+
"p",
|
|
868
|
+
"br",
|
|
869
|
+
"div",
|
|
870
|
+
"span",
|
|
871
|
+
"strong",
|
|
872
|
+
"em",
|
|
873
|
+
"b",
|
|
874
|
+
"i",
|
|
875
|
+
"u",
|
|
876
|
+
"a",
|
|
877
|
+
"ul",
|
|
878
|
+
"ol",
|
|
879
|
+
"li",
|
|
880
|
+
"h1",
|
|
881
|
+
"h2",
|
|
882
|
+
"h3",
|
|
883
|
+
"h4",
|
|
884
|
+
"h5",
|
|
885
|
+
"h6",
|
|
886
|
+
"table",
|
|
887
|
+
"tr",
|
|
888
|
+
"td",
|
|
889
|
+
"th",
|
|
890
|
+
"img",
|
|
891
|
+
"pre",
|
|
892
|
+
"code"
|
|
893
|
+
]);
|
|
894
|
+
function isHtml(content) {
|
|
895
|
+
let match;
|
|
896
|
+
HTML_TAG_REGEX.lastIndex = 0;
|
|
897
|
+
while ((match = HTML_TAG_REGEX.exec(content)) !== null) {
|
|
898
|
+
if (HTML_TAGS.has(match[1].toLowerCase())) {
|
|
899
|
+
return true;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
return false;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// src/services/md-convert.ts
|
|
906
|
+
function htmlToMarkdown(html) {
|
|
907
|
+
return NodeHtmlMarkdown.translate(html);
|
|
908
|
+
}
|
|
909
|
+
function toMarkdown(content) {
|
|
910
|
+
if (isHtml(content)) {
|
|
911
|
+
return htmlToMarkdown(content);
|
|
912
|
+
}
|
|
913
|
+
return content;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// src/commands/get-md-field.ts
|
|
917
|
+
function createGetMdFieldCommand() {
|
|
918
|
+
const command = new Command7("get-md-field");
|
|
919
|
+
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(
|
|
920
|
+
async (idStr, field, options) => {
|
|
921
|
+
const id = parseWorkItemId(idStr);
|
|
922
|
+
validateOrgProjectPair(options);
|
|
923
|
+
let context;
|
|
924
|
+
try {
|
|
925
|
+
context = resolveContext(options);
|
|
926
|
+
const credential = await resolvePat();
|
|
927
|
+
const value = await getWorkItemFieldValue(context, id, credential.pat, field);
|
|
928
|
+
if (value === null) {
|
|
929
|
+
process.stdout.write("\n");
|
|
930
|
+
} else {
|
|
931
|
+
process.stdout.write(toMarkdown(value) + "\n");
|
|
932
|
+
}
|
|
933
|
+
} catch (err) {
|
|
934
|
+
handleCommandError(err, id, context, "read");
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
);
|
|
938
|
+
return command;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// src/commands/set-md-field.ts
|
|
942
|
+
import { existsSync, readFileSync as readFileSync2 } from "fs";
|
|
943
|
+
import { Command as Command8 } from "commander";
|
|
944
|
+
function fail(message) {
|
|
945
|
+
process.stderr.write(`Error: ${message}
|
|
946
|
+
`);
|
|
947
|
+
process.exit(1);
|
|
948
|
+
}
|
|
949
|
+
function resolveContent(inlineContent, options) {
|
|
950
|
+
if (inlineContent && options.file) {
|
|
951
|
+
fail("Cannot specify both inline content and --file.");
|
|
952
|
+
}
|
|
953
|
+
if (options.file) {
|
|
954
|
+
return readFileContent(options.file);
|
|
955
|
+
}
|
|
956
|
+
if (inlineContent) {
|
|
957
|
+
return inlineContent;
|
|
958
|
+
}
|
|
959
|
+
return null;
|
|
960
|
+
}
|
|
961
|
+
function readFileContent(filePath) {
|
|
962
|
+
if (!existsSync(filePath)) {
|
|
963
|
+
fail(`File not found: ${filePath}`);
|
|
964
|
+
}
|
|
965
|
+
try {
|
|
966
|
+
return readFileSync2(filePath, "utf-8");
|
|
967
|
+
} catch {
|
|
968
|
+
fail(`Cannot read file: ${filePath}`);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
async function readStdinContent() {
|
|
972
|
+
if (process.stdin.isTTY) {
|
|
973
|
+
fail(
|
|
974
|
+
"No content provided. Pass markdown content as the third argument, use --file, or pipe via stdin."
|
|
975
|
+
);
|
|
976
|
+
}
|
|
977
|
+
const chunks = [];
|
|
978
|
+
for await (const chunk of process.stdin) {
|
|
979
|
+
chunks.push(chunk);
|
|
980
|
+
}
|
|
981
|
+
const stdinContent = Buffer.concat(chunks).toString("utf-8").trimEnd();
|
|
982
|
+
if (!stdinContent) {
|
|
983
|
+
fail(
|
|
984
|
+
"No content provided via stdin. Pipe markdown content or use inline content or --file."
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
return stdinContent;
|
|
988
|
+
}
|
|
989
|
+
function formatOutput(result, options, field) {
|
|
990
|
+
if (options.json) {
|
|
991
|
+
process.stdout.write(
|
|
992
|
+
JSON.stringify({
|
|
993
|
+
id: result.id,
|
|
994
|
+
rev: result.rev,
|
|
995
|
+
field: result.fieldName,
|
|
996
|
+
value: result.fieldValue
|
|
997
|
+
}) + "\n"
|
|
998
|
+
);
|
|
999
|
+
} else {
|
|
1000
|
+
process.stdout.write(`Updated work item ${result.id}: ${field} set with markdown content
|
|
1001
|
+
`);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
function createSetMdFieldCommand() {
|
|
1005
|
+
const command = new Command8("set-md-field");
|
|
1006
|
+
command.description("Set a work item field with markdown content").argument("<id>", "work item ID").argument("<field>", "field reference name (e.g., System.Description)").argument("[content]", "markdown content to set").option("--org <org>", "Azure DevOps organization").option("--project <project>", "Azure DevOps project").option("--json", "output result as JSON").option("--file <path>", "read markdown content from file").action(
|
|
1007
|
+
async (idStr, field, inlineContent, options) => {
|
|
1008
|
+
const id = parseWorkItemId(idStr);
|
|
1009
|
+
validateOrgProjectPair(options);
|
|
1010
|
+
const content = resolveContent(inlineContent, options) ?? await readStdinContent();
|
|
1011
|
+
let context;
|
|
1012
|
+
try {
|
|
1013
|
+
context = resolveContext(options);
|
|
1014
|
+
const credential = await resolvePat();
|
|
1015
|
+
const operations = [
|
|
1016
|
+
{ op: "add", path: `/fields/${field}`, value: content },
|
|
1017
|
+
{ op: "add", path: `/multilineFieldsFormat/${field}`, value: "Markdown" }
|
|
1018
|
+
];
|
|
1019
|
+
const result = await updateWorkItem(context, id, credential.pat, field, operations);
|
|
1020
|
+
formatOutput(result, options, field);
|
|
1021
|
+
} catch (err) {
|
|
1022
|
+
handleCommandError(err, id, context, "write");
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
);
|
|
1026
|
+
return command;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
14
1029
|
// src/index.ts
|
|
15
|
-
var program = new
|
|
1030
|
+
var program = new Command9();
|
|
16
1031
|
program.name("azdo").description("Azure DevOps CLI tool").version(version, "-v, --version");
|
|
1032
|
+
program.addCommand(createGetItemCommand());
|
|
1033
|
+
program.addCommand(createClearPatCommand());
|
|
1034
|
+
program.addCommand(createConfigCommand());
|
|
1035
|
+
program.addCommand(createSetStateCommand());
|
|
1036
|
+
program.addCommand(createAssignCommand());
|
|
1037
|
+
program.addCommand(createSetFieldCommand());
|
|
1038
|
+
program.addCommand(createGetMdFieldCommand());
|
|
1039
|
+
program.addCommand(createSetMdFieldCommand());
|
|
17
1040
|
program.showHelpAfterError();
|
|
18
1041
|
program.parse();
|
|
19
1042
|
if (process.argv.length <= 2) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "azdo-cli",
|
|
3
|
-
"version": "0.2.0-
|
|
3
|
+
"version": "0.2.0-hotfix-0-2-5.63",
|
|
4
4
|
"description": "Azure DevOps CLI tool",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"lint": "eslint src/",
|
|
15
15
|
"typecheck": "tsc --noEmit",
|
|
16
16
|
"format": "prettier --check src/",
|
|
17
|
-
"test": "vitest run"
|
|
17
|
+
"test": "npm run build && vitest run"
|
|
18
18
|
},
|
|
19
19
|
"repository": {
|
|
20
20
|
"type": "git",
|
|
@@ -22,7 +22,9 @@
|
|
|
22
22
|
},
|
|
23
23
|
"license": "MIT",
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"
|
|
25
|
+
"@napi-rs/keyring": "^1.2.0",
|
|
26
|
+
"commander": "^14.0.3",
|
|
27
|
+
"node-html-markdown": "^2.0.0"
|
|
26
28
|
},
|
|
27
29
|
"devDependencies": {
|
|
28
30
|
"@eslint/js": "^10.0.1",
|