@falldownthesystem/devtool 0.4.1 → 0.6.1
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 +167 -9
- package/package.json +7 -3
- package/src/clients/bitbucket.js +22 -26
- package/src/clients/confluence.js +126 -23
- package/src/clients/graph-auth.js +110 -0
- package/src/clients/graph-cache.js +148 -0
- package/src/clients/graph.js +263 -0
- package/src/clients/jira.js +20 -20
- package/src/clients/serviceDesk.js +33 -37
- package/src/commands/config.js +71 -27
- package/src/commands/confluence.js +19 -23
- package/src/commands/graph.js +147 -0
- package/src/commands/jira.js +6 -5
- package/src/commands/pr.js +6 -22
- package/src/commands/sd.js +8 -7
- package/src/config/setup.js +113 -0
- package/src/config/store.js +207 -171
- package/src/formatters/graph.js +220 -0
- package/src/index.js +9 -20
- package/src/utils/connection.js +5 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# dev
|
|
2
2
|
|
|
3
|
-
A terminal-first CLI for
|
|
3
|
+
A terminal-first CLI for Bitbucket PRs, Jira tickets, Confluence pages, Service Desk tickets, and Microsoft Graph. Microsoft Graph provides read-only access to Teams chats, email, calendars, and SharePoint.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -8,7 +8,7 @@ A terminal-first CLI for working with Bitbucket PRs, Jira tickets, and Confluenc
|
|
|
8
8
|
npm install -g @falldownthesystem/devtool
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
This gives you the `dev` command globally.
|
|
11
|
+
This gives you the `dev` command globally. Add a connection for each service you use with `dev config add`; services you don't use need no configuration.
|
|
12
12
|
|
|
13
13
|
## Usage
|
|
14
14
|
|
|
@@ -30,9 +30,13 @@ dev confluence view 12345 # read a page
|
|
|
30
30
|
dev confluence pages # pages in a space
|
|
31
31
|
dev confluence search "auth" # CQL search
|
|
32
32
|
|
|
33
|
-
dev config setup #
|
|
34
|
-
dev config
|
|
35
|
-
dev config
|
|
33
|
+
dev config setup # add or edit connections interactively
|
|
34
|
+
dev config add jira -n work # add a named Jira connection
|
|
35
|
+
dev config edit work # edit a connection interactively
|
|
36
|
+
dev config remove work # remove a connection
|
|
37
|
+
dev config default work # make it the default for its type
|
|
38
|
+
dev config show # all connections (tokens masked)
|
|
39
|
+
dev config set <key> <value> # set a single field, e.g. work.baseUrl
|
|
36
40
|
```
|
|
37
41
|
|
|
38
42
|
Most commands have short aliases: `ls`, `read`, `find`, `my`.
|
|
@@ -47,13 +51,167 @@ dev pr list --plain # no colors, no tables, pipe-friendly
|
|
|
47
51
|
dev pr list --json # curated JSON, not raw API dumps
|
|
48
52
|
```
|
|
49
53
|
|
|
54
|
+
### Multiple connections
|
|
55
|
+
|
|
56
|
+
You can add any number of connections of each type. A connection's name is optional: an unnamed connection is referred to by its type (`jira`), and only one unnamed connection per type is allowed. Commands use the default connection of their type; pick another with `--connection`:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
dev jira view PROJ-123 # default Jira connection
|
|
60
|
+
dev jira view OPS-7 --connection work # the Jira connection named "work"
|
|
61
|
+
dev confluence spaces -c wiki # the Confluence connection named "wiki"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The default is the connection marked with `dev config default`, or otherwise the first connection of that type.
|
|
65
|
+
|
|
50
66
|
## Config
|
|
51
67
|
|
|
52
|
-
Stored via the `conf` package in your OS config directory.
|
|
68
|
+
Stored via the `conf` package in your OS config directory. Each connection has a type and its own fields:
|
|
69
|
+
|
|
70
|
+
- **bitbucket**: workspace, default repo, email, app password
|
|
71
|
+
- **jira**: base URL, API token, default project
|
|
72
|
+
- **confluence**: base URL, email, API token, default space. Cloud and Server/Data Center are detected from the base URL. For Server/Data Center, include any context path (`https://company.com/confluence`) and leave the email empty to use a personal access token, or set it to your username for Basic auth.
|
|
73
|
+
- **serviceDesk**: base URL, email, API token, default project
|
|
74
|
+
- **graph**: tenant ID, client ID, default SharePoint site (see [Microsoft Graph](#microsoft-graph))
|
|
75
|
+
|
|
76
|
+
`dev config set <connection>.<field> <value>` addresses a connection by name or type, so the older keys such as `bitbucket.workspace` keep working. Configuration from earlier versions, with one section per service, is converted to unnamed connections on first run.
|
|
77
|
+
|
|
78
|
+
## Microsoft Graph
|
|
79
|
+
|
|
80
|
+
A Graph connection uses one application registration and one sign-in for all four services. No PAT or client secret is required. Graph commands do not require Bitbucket or Jira configuration. Like other connection types, you can add several Graph connections (for example one per tenant) and pick one with `--connection`.
|
|
81
|
+
|
|
82
|
+
### Application registration and setup
|
|
83
|
+
|
|
84
|
+
1. Register a single-tenant application in the [Microsoft Entra admin center](https://entra.microsoft.com).
|
|
85
|
+
2. Under **Authentication**, add **Mobile and desktop applications** with redirect URI `http://localhost`.
|
|
86
|
+
3. Add Microsoft Graph **delegated** permissions: `User.Read`, `Chat.Read`, `Mail.Read`, `Sites.Read.All`.
|
|
87
|
+
4. Obtain administrator approval if your organization requires it.
|
|
88
|
+
5. Run the Graph wizard, then sign in through your browser:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
dev graph setup # edits the selected Graph connection, or adds one; also: dev config add graph
|
|
92
|
+
dev graph login
|
|
93
|
+
dev graph status # cached account only, not a token-validity check
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The wizard asks for the **Directory (tenant) ID**, **Application (client) ID**, and an optional default SharePoint site URL. These IDs are not secrets. You can also set them individually:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
dev config set graph.tenantId <tenant-uuid>
|
|
100
|
+
dev config set graph.clientId <client-uuid>
|
|
101
|
+
dev config set graph.defaultSite https://company.sharepoint.com/sites/MySite
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`graph.<field>` addresses the default Graph connection; use `<name>.<field>` for a named one. Setup and login require an interactive terminal without `--json` or `--plain`. Data commands never prompt or open a browser. If sign-in expires or needs fresh consent, run `dev graph login` again.
|
|
105
|
+
|
|
106
|
+
Tokens are kept in a cache file outside the repository, partitioned by tenant and client ID and encrypted with AES-256-GCM. The encryption key is stored in the OS credential store: Windows Credential Manager, macOS Keychain, or Linux Secret Service (required on Linux). There is no plaintext fallback. The credential store binding ships prebuilt for each platform, so installation needs no build tools or install scripts. `dev graph logout` removes the local cached account for the current tenant/client pair; it does not revoke grants or sign out browser sessions. Changing IDs selects another cache; log out before changing IDs if you want to remove the old account.
|
|
107
|
+
|
|
108
|
+
For one sign-in covering every supported service, add the optional delegated permissions `Mail.Read.Shared`, `Calendars.Read.Shared`, and `Calendars.ReadBasic`, then run:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
dev graph login --all
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
This is an alternative to the individual login flags, not an additional login step. The application name in the consent screen comes from your Entra registration. A permission marked **Admin consent required: No** can still require approval under your organization's user-consent policy. Changing authentication flows does not bypass that policy.
|
|
115
|
+
|
|
116
|
+
### Teams chats
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
dev graph teams chats --limit 25
|
|
120
|
+
dev graph teams view "<chat-id>"
|
|
121
|
+
dev graph teams messages "<chat-id>" --limit 50
|
|
122
|
+
dev graph teams message "<chat-id>" "<message-id>"
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
This covers your chats, not Teams channels. Expanded chat member lists can stop at 25 members.
|
|
126
|
+
|
|
127
|
+
### Email
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
dev graph mail folders
|
|
131
|
+
dev graph mail folders --parent "<folder-id>"
|
|
132
|
+
dev graph mail list --folder inbox --limit 25
|
|
133
|
+
dev graph mail search 'subject:release' --limit 50
|
|
134
|
+
dev graph mail view "<message-id>"
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`email` is an alias for `mail`. Reads do not mark messages as read. Search uses Microsoft's message search syntax and service-defined order, with a maximum of 1,000 matches. The CLI displays message bodies as text; it does not download email attachments.
|
|
138
|
+
|
|
139
|
+
For shared mailboxes or another person's shared folders, add the **delegated** `Mail.Read.Shared` permission and sign in with that scope:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
dev graph login --shared-mail
|
|
143
|
+
dev graph mail --mailbox shared@company.com folders
|
|
144
|
+
dev graph mail --mailbox shared@company.com list --folder inbox
|
|
145
|
+
dev graph mail --mailbox owner@company.com view "<message-id>" --folder "<shared-folder-id>"
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
You also need Exchange access to the mailbox or folder. `Mail.Read` alone covers only your own mailbox. `--mailbox` accepts the mailbox owner's address or user ID; Graph does not discover your shared mailboxes. With folder-only access, specify that folder instead of requesting the entire mailbox. Normal commands do not request shared-mail permission.
|
|
149
|
+
|
|
150
|
+
### Calendars
|
|
151
|
+
|
|
152
|
+
Add the **delegated** `Calendars.Read.Shared` and `Calendars.ReadBasic` permissions, then sign in with `--calendars` (or `--all`). These cover calendar details and the free/busy query. Calendar details include your calendars and shared calendars you already have permission to read. It does not grant new Exchange access or allow event changes.
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
dev graph login --calendars
|
|
156
|
+
dev graph login --calendars --shared-mail # both optional permissions in one sign-in
|
|
157
|
+
dev graph calendar list
|
|
158
|
+
dev graph calendar next
|
|
159
|
+
dev graph calendar events --limit 50
|
|
160
|
+
dev graph calendar events --from 2026-09-14T00:00:00+03:00 --to 2026-09-21T00:00:00+03:00
|
|
161
|
+
dev graph calendar --calendar "<calendar-id>" next
|
|
162
|
+
dev graph calendar --mailbox owner@company.com --calendar "<calendar-id>" events
|
|
163
|
+
dev graph calendar --calendar "<calendar-id>" view "<event-id>"
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`calendars` aliases `calendar`. `list` returns calendars in the addressed mailbox, including shared calendars added there. Supply `--mailbox` to address the owner directly when needed. Calendar IDs belong to the mailbox context that returned them. The permission does not discover every calendar across the organization.
|
|
167
|
+
|
|
168
|
+
`events`, `next`, and `view` use the primary calendar unless you supply `--calendar`; they do not merge all calendars. Event times use UTC. Date inputs require ISO 8601 with an explicit timezone. The default window starts now and ends 30 days later; custom windows can span up to 366 days. The client expands recurring meetings through `calendarView`, reads the complete window (up to 1,000 events), and sorts before selecting results. If the window exceeds 1,000 events, narrow the date range instead of accepting an unreliable next event.
|
|
169
|
+
|
|
170
|
+
`next` finds the next appointment or meeting in the selected calendar. It excludes cancelled, declined, and already-started events. If no event qualifies, the result includes the checked date range. It does not change attendance, read status, or calendar contents.
|
|
171
|
+
|
|
172
|
+
### Other users' free/busy times
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
dev graph calendar availability colleague@company.com
|
|
176
|
+
dev graph calendar availability colleague@company.com room@company.com --from 2026-09-14T09:00:00+03:00 --to 2026-09-14T17:00:00+03:00 --interval 30
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
This calls Microsoft's read-only `getSchedule` query using `Calendars.ReadBasic`. It requires no event creation or attendance changes. Unlike full shared-calendar details, free/busy availability follows Exchange publishing and organization sharing policies. Private subjects and details may remain hidden.
|
|
180
|
+
|
|
181
|
+
Supply 1–20 email addresses, not `--mailbox` or `--calendar`. The default range is now through 24 hours later. Custom ranges must be shorter than 62 days; intervals accept 5–1440 minutes. Results include UTC blocks and a compact slot string: `0` = free or working elsewhere, `1` = tentative, `2` = busy, `3` = out of office. Per-person errors remain visible and cause a nonzero exit status; missing or denied availability never means free. If Microsoft reports error `5006`, narrow the range or increase the interval.
|
|
182
|
+
|
|
183
|
+
### SharePoint
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
dev graph sharepoint sites "Product"
|
|
187
|
+
dev graph sharepoint site
|
|
188
|
+
dev graph sharepoint --site https://company.sharepoint.com/sites/Other libraries
|
|
189
|
+
dev graph sharepoint libraries
|
|
190
|
+
dev graph sharepoint files "<drive-id>"
|
|
191
|
+
dev graph sharepoint files "<drive-id>" --path "Product Data/Documentation/My Product"
|
|
192
|
+
dev graph sharepoint files "<drive-id>" --folder "<folder-item-id>"
|
|
193
|
+
dev graph sharepoint file "<drive-id>" "<item-id>"
|
|
194
|
+
dev graph sharepoint download "<drive-id>" "<item-id>" "./document.pdf"
|
|
195
|
+
dev graph sharepoint lists
|
|
196
|
+
dev graph sharepoint items "<list-id>"
|
|
197
|
+
dev graph sharepoint item "<list-id>" "<item-id>"
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
`sp` is an alias for `sharepoint`. Site-based commands use the connection's default site unless you supply `--site`. File commands use the explicit drive ID. Select the **Documents** library from `libraries`; `--path` starts inside that library, so omit the `Documents/` prefix. File downloads use a temporary file and expose the destination only after the transfer completes. An atomic hard link prevents overwriting a destination created concurrently; the destination filesystem must support hard links. Normal failures clean up temporary files. A forced process termination may leave a `.devtool-download-*` temporary directory, but never a partial destination. Each download request defaults to a 120-second timeout; use `--timeout <seconds>` (1–86400) for slower transfers. Other Graph requests have a 60-second timeout. The download changes no remote data and sends no Graph bearer token to the SharePoint download host.
|
|
201
|
+
|
|
202
|
+
### Output and limits
|
|
203
|
+
|
|
204
|
+
All Graph data commands support rich tables, `--plain`, and curated `--json`. Collection commands default to 25 results; `--limit` accepts 1–1000. The client follows server pagination up to that limit, so more results can exist. JSON lists are arrays. Errors go to stderr with a nonzero exit status; `--json` errors use `{"error":{"message":"...","status":403}}`. Authentication tokens, raw Graph metadata, and preauthenticated file download URLs never appear in curated output.
|
|
205
|
+
|
|
206
|
+
Content reads use GET. The only POST is the fixed `/me/calendar/getSchedule` endpoint, which computes free/busy information without changing data. The CLI has no Graph mail-send, message-send, upload, update, or delete commands.
|
|
207
|
+
|
|
208
|
+
## Development
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
npm test
|
|
212
|
+
```
|
|
53
213
|
|
|
54
|
-
|
|
55
|
-
- **Jira**: base URL, API token, default project
|
|
56
|
-
- **Confluence**: uses the same Jira base URL and token
|
|
214
|
+
The Node.js test suite uses mock Graph and authentication responses, temporary configuration, and local download fixtures. It does not access Microsoft 365 data.
|
|
57
215
|
|
|
58
216
|
## License
|
|
59
217
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@falldownthesystem/devtool",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "CLI for Bitbucket
|
|
3
|
+
"version": "0.6.1",
|
|
4
|
+
"description": "CLI for Bitbucket, Jira, Confluence, Service Desk, and Microsoft Graph",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"dev": "src/index.js"
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
],
|
|
12
12
|
"scripts": {
|
|
13
13
|
"start": "node src/index.js",
|
|
14
|
-
"test": "node --test test/config.test.js test/confluence.test.js test/service-desk.test.js"
|
|
14
|
+
"test": "node --test test/config.test.js test/confluence.test.js test/service-desk.test.js test/graph.test.js"
|
|
15
15
|
},
|
|
16
16
|
"keywords": [
|
|
17
17
|
"cli",
|
|
@@ -31,11 +31,15 @@
|
|
|
31
31
|
"node": ">=18"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
+
"@azure/msal-node": "^6.0.0",
|
|
34
35
|
"@clack/prompts": "^1.0.0",
|
|
36
|
+
"@napi-rs/keyring": "^2.1.2",
|
|
35
37
|
"cli-table3": "^0.6.5",
|
|
36
38
|
"commander": "^14.0.3",
|
|
37
39
|
"conf": "^15.1.0",
|
|
40
|
+
"html-to-text": "^10.0.1",
|
|
38
41
|
"jira.js": "^5.3.0",
|
|
42
|
+
"open": "^11.0.3",
|
|
39
43
|
"picocolors": "^1.1.1"
|
|
40
44
|
}
|
|
41
45
|
}
|
package/src/clients/bitbucket.js
CHANGED
|
@@ -1,20 +1,16 @@
|
|
|
1
|
-
import { get } from '../config/store.js';
|
|
2
|
-
|
|
3
1
|
const BASE_URL = 'https://api.bitbucket.org/2.0';
|
|
4
2
|
|
|
5
|
-
function authHeader() {
|
|
6
|
-
|
|
7
|
-
const token = get('bitbucket.token');
|
|
8
|
-
return 'Basic ' + Buffer.from(`${email}:${token}`).toString('base64');
|
|
3
|
+
function authHeader(connection) {
|
|
4
|
+
return 'Basic ' + Buffer.from(`${connection.email}:${connection.token}`).toString('base64');
|
|
9
5
|
}
|
|
10
6
|
|
|
11
|
-
function repoPath(repoOverride) {
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
return `/repositories/${workspace}/${repo}`;
|
|
7
|
+
function repoPath(connection, repoOverride) {
|
|
8
|
+
const repo = repoOverride || connection.defaultRepo;
|
|
9
|
+
if (!repo) throw new Error('No repository specified. Pass --repo <slug> or set defaultRepo on the Bitbucket connection.');
|
|
10
|
+
return `/repositories/${connection.workspace}/${repo}`;
|
|
15
11
|
}
|
|
16
12
|
|
|
17
|
-
async function request(path, params = {}) {
|
|
13
|
+
async function request(connection, path, params = {}) {
|
|
18
14
|
const url = new URL(path.startsWith('http') ? path : `${BASE_URL}${path}`);
|
|
19
15
|
for (const [k, v] of Object.entries(params)) {
|
|
20
16
|
if (v !== undefined && v !== null) url.searchParams.set(k, v);
|
|
@@ -22,7 +18,7 @@ async function request(path, params = {}) {
|
|
|
22
18
|
|
|
23
19
|
const res = await fetch(url, {
|
|
24
20
|
headers: {
|
|
25
|
-
'Authorization': authHeader(),
|
|
21
|
+
'Authorization': authHeader(connection),
|
|
26
22
|
'Accept': 'application/json',
|
|
27
23
|
},
|
|
28
24
|
});
|
|
@@ -37,25 +33,25 @@ async function request(path, params = {}) {
|
|
|
37
33
|
return res.json();
|
|
38
34
|
}
|
|
39
35
|
|
|
40
|
-
async function paginate(path, params = {}) {
|
|
36
|
+
async function paginate(connection, path, params = {}) {
|
|
41
37
|
const results = [];
|
|
42
|
-
let data = await request(path, params);
|
|
38
|
+
let data = await request(connection, path, params);
|
|
43
39
|
results.push(...(data.values || []));
|
|
44
40
|
|
|
45
41
|
while (data.next) {
|
|
46
|
-
data = await request(data.next);
|
|
42
|
+
data = await request(connection, data.next);
|
|
47
43
|
results.push(...(data.values || []));
|
|
48
44
|
}
|
|
49
45
|
|
|
50
46
|
return results;
|
|
51
47
|
}
|
|
52
48
|
|
|
53
|
-
export async function listPullRequests(options = {}) {
|
|
49
|
+
export async function listPullRequests(connection, options = {}) {
|
|
54
50
|
const { state = 'OPEN', author, repo } = options;
|
|
55
|
-
const path = `${repoPath(repo)}/pullrequests`;
|
|
51
|
+
const path = `${repoPath(connection, repo)}/pullrequests`;
|
|
56
52
|
const params = { state, pagelen: 50 };
|
|
57
53
|
|
|
58
|
-
const prs = await paginate(path, params);
|
|
54
|
+
const prs = await paginate(connection, path, params);
|
|
59
55
|
|
|
60
56
|
if (author) {
|
|
61
57
|
const authorLower = author.toLowerCase();
|
|
@@ -68,18 +64,18 @@ export async function listPullRequests(options = {}) {
|
|
|
68
64
|
return prs;
|
|
69
65
|
}
|
|
70
66
|
|
|
71
|
-
export async function getPullRequest(id, repo) {
|
|
72
|
-
return request(`${repoPath(repo)}/pullrequests/${id}`);
|
|
67
|
+
export async function getPullRequest(connection, id, repo) {
|
|
68
|
+
return request(connection, `${repoPath(connection, repo)}/pullrequests/${id}`);
|
|
73
69
|
}
|
|
74
70
|
|
|
75
|
-
export async function getPullRequestComments(id, repo) {
|
|
76
|
-
return paginate(`${repoPath(repo)}/pullrequests/${id}/comments`);
|
|
71
|
+
export async function getPullRequestComments(connection, id, repo) {
|
|
72
|
+
return paginate(connection, `${repoPath(connection, repo)}/pullrequests/${id}/comments`);
|
|
77
73
|
}
|
|
78
74
|
|
|
79
|
-
export async function getPullRequestDiffstat(id, repo) {
|
|
80
|
-
return paginate(`${repoPath(repo)}/pullrequests/${id}/diffstat`);
|
|
75
|
+
export async function getPullRequestDiffstat(connection, id, repo) {
|
|
76
|
+
return paginate(connection, `${repoPath(connection, repo)}/pullrequests/${id}/diffstat`);
|
|
81
77
|
}
|
|
82
78
|
|
|
83
|
-
export async function getPullRequestActivity(id, repo) {
|
|
84
|
-
return paginate(`${repoPath(repo)}/pullrequests/${id}/activity`);
|
|
79
|
+
export async function getPullRequestActivity(connection, id, repo) {
|
|
80
|
+
return paginate(connection, `${repoPath(connection, repo)}/pullrequests/${id}/activity`);
|
|
85
81
|
}
|
|
@@ -1,18 +1,50 @@
|
|
|
1
|
-
|
|
1
|
+
const deployments = new Map();
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
// Custom domains hide the atlassian.net hostname, so anything else is probed:
|
|
4
|
+
// Atlassian's edge answers tenant_info anonymously, Server/Data Center does not.
|
|
5
|
+
async function detectDeployment(origin) {
|
|
6
|
+
if (/\.atlassian\.net$/i.test(new URL(origin).hostname)) return 'cloud';
|
|
7
|
+
try {
|
|
8
|
+
const res = await fetch(new URL('/_edge/tenant_info', origin), {
|
|
9
|
+
headers: { 'Accept': 'application/json' },
|
|
10
|
+
});
|
|
11
|
+
if (res.ok && (await res.json()).cloudId) return 'cloud';
|
|
12
|
+
} catch {
|
|
13
|
+
// An unreachable or non-JSON probe means no Atlassian edge; the real request reports connection errors.
|
|
14
|
+
}
|
|
15
|
+
return 'server';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function connect({ baseUrl, email, token }) {
|
|
19
|
+
const base = new URL(baseUrl);
|
|
20
|
+
if (!deployments.has(base.origin)) deployments.set(base.origin, detectDeployment(base.origin));
|
|
21
|
+
const kind = await deployments.get(base.origin);
|
|
22
|
+
// Cloud always serves Confluence under /wiki; Server/Data Center keeps its own context path.
|
|
23
|
+
const path = kind === 'cloud' ? '/wiki' : base.pathname.replace(/\/+$/, '');
|
|
24
|
+
return { kind, origin: base.origin, path, email, token };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function apiUrl(conn, path) {
|
|
28
|
+
return new URL(`${conn.origin}${conn.path}${path}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Cloud v2 links are origin-relative, Server/Data Center links are relative to the context path.
|
|
32
|
+
function nextUrl(conn, link, current) {
|
|
33
|
+
const contextRelative = conn.kind === 'server'
|
|
34
|
+
&& link.startsWith('/') && !link.startsWith('//')
|
|
35
|
+
&& !(conn.path && link.startsWith(`${conn.path}/`));
|
|
36
|
+
return contextRelative ? apiUrl(conn, link) : new URL(link, current);
|
|
5
37
|
}
|
|
6
38
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
39
|
+
// Server/Data Center personal access tokens use Bearer auth; a username switches to Basic.
|
|
40
|
+
function authHeader({ kind, email = '', token = '' }) {
|
|
41
|
+
if (kind === 'server' && !email) return `Bearer ${token}`;
|
|
10
42
|
return 'Basic ' + Buffer.from(`${email}:${token}`).toString('base64');
|
|
11
43
|
}
|
|
12
44
|
|
|
13
|
-
async function request(url, params = {}) {
|
|
14
|
-
if (url.origin !==
|
|
15
|
-
throw new Error('Confluence pagination points to a different site. Check
|
|
45
|
+
async function request(conn, url, params = {}) {
|
|
46
|
+
if (url.origin !== conn.origin) {
|
|
47
|
+
throw new Error('Confluence pagination points to a different site. Check the base URL of the Confluence connection.');
|
|
16
48
|
}
|
|
17
49
|
for (const [k, v] of Object.entries(params)) {
|
|
18
50
|
if (v !== undefined && v !== null) url.searchParams.set(k, v);
|
|
@@ -20,7 +52,7 @@ async function request(url, params = {}) {
|
|
|
20
52
|
|
|
21
53
|
const res = await fetch(url, {
|
|
22
54
|
headers: {
|
|
23
|
-
'Authorization': authHeader(),
|
|
55
|
+
'Authorization': authHeader(conn),
|
|
24
56
|
'Accept': 'application/json',
|
|
25
57
|
},
|
|
26
58
|
});
|
|
@@ -32,36 +64,107 @@ async function request(url, params = {}) {
|
|
|
32
64
|
throw err;
|
|
33
65
|
}
|
|
34
66
|
|
|
67
|
+
// Server/Data Center answers rejected credentials with a login page instead of a 401.
|
|
68
|
+
if (!res.headers.get('content-type')?.includes('json')) {
|
|
69
|
+
const err = new Error('Confluence returned a non-JSON response. Check the base URL and token of the Confluence connection.');
|
|
70
|
+
err.url = url.toString();
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
|
|
35
74
|
return res.json();
|
|
36
75
|
}
|
|
37
76
|
|
|
38
|
-
async function
|
|
77
|
+
async function paginate(conn, path, params = {}) {
|
|
39
78
|
const results = [];
|
|
40
|
-
let url = apiUrl(path);
|
|
41
|
-
let data = await request(url, params);
|
|
79
|
+
let url = apiUrl(conn, path);
|
|
80
|
+
let data = await request(conn, url, params);
|
|
42
81
|
results.push(...(data.results || []));
|
|
43
82
|
|
|
44
83
|
while (data._links?.next) {
|
|
45
|
-
url =
|
|
46
|
-
data = await request(url);
|
|
84
|
+
url = nextUrl(conn, data._links.next, url);
|
|
85
|
+
data = await request(conn, url);
|
|
47
86
|
results.push(...(data.results || []));
|
|
48
87
|
}
|
|
49
88
|
|
|
50
89
|
return results;
|
|
51
90
|
}
|
|
52
91
|
|
|
53
|
-
|
|
54
|
-
|
|
92
|
+
// Server/Data Center v1 content is reshaped to the Cloud v2 fields the formatters read.
|
|
93
|
+
function serverSpace(space) {
|
|
94
|
+
return {
|
|
95
|
+
id: String(space.id),
|
|
96
|
+
key: space.key,
|
|
97
|
+
name: space.name,
|
|
98
|
+
type: space.type,
|
|
99
|
+
status: space.status,
|
|
100
|
+
};
|
|
55
101
|
}
|
|
56
102
|
|
|
57
|
-
|
|
58
|
-
return
|
|
103
|
+
function serverPage(page) {
|
|
104
|
+
return {
|
|
105
|
+
id: page.id,
|
|
106
|
+
title: page.title,
|
|
107
|
+
status: page.status,
|
|
108
|
+
spaceId: page.space?.id != null ? String(page.space.id) : undefined,
|
|
109
|
+
createdAt: page.history?.createdDate,
|
|
110
|
+
version: page.version ? { number: page.version.number } : undefined,
|
|
111
|
+
body: page.body,
|
|
112
|
+
};
|
|
59
113
|
}
|
|
60
114
|
|
|
61
|
-
|
|
62
|
-
|
|
115
|
+
// The content listing silently returns nothing for an unknown space key, so the space is looked up first.
|
|
116
|
+
async function serverSpaceKey(conn, { spaceId, spaceKey }) {
|
|
117
|
+
if (!spaceKey) return (await spaces(conn)).find((s) => s.id === String(spaceId))?.key;
|
|
118
|
+
try {
|
|
119
|
+
return (await request(conn, apiUrl(conn, `/rest/api/space/${encodeURIComponent(spaceKey)}`))).key;
|
|
120
|
+
} catch (err) {
|
|
121
|
+
if (err.status === 404) return undefined;
|
|
122
|
+
throw err;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function spaces(conn) {
|
|
127
|
+
if (conn.kind === 'cloud') return paginate(conn, '/api/v2/spaces');
|
|
128
|
+
return (await paginate(conn, '/rest/api/space', { limit: 100 })).map(serverSpace);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function listSpaces(connection) {
|
|
132
|
+
return spaces(await connect(connection));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function getPage(connection, id) {
|
|
136
|
+
const conn = await connect(connection);
|
|
137
|
+
if (conn.kind === 'cloud') {
|
|
138
|
+
return request(conn, apiUrl(conn, `/api/v2/pages/${id}`), { 'body-format': 'storage' });
|
|
139
|
+
}
|
|
140
|
+
return serverPage(await request(conn, apiUrl(conn, `/rest/api/content/${id}`), {
|
|
141
|
+
expand: 'body.storage,version,space,history',
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Cloud lists pages by space ID and Server/Data Center by space key; returns null for an unknown space.
|
|
146
|
+
export async function listSpacePages(connection, { spaceId, spaceKey }) {
|
|
147
|
+
const conn = await connect(connection);
|
|
148
|
+
if (conn.kind === 'cloud') {
|
|
149
|
+
const id = spaceId
|
|
150
|
+
?? (await request(conn, apiUrl(conn, '/api/v2/spaces'), { keys: spaceKey })).results?.[0]?.id;
|
|
151
|
+
if (!id) return null;
|
|
152
|
+
return paginate(conn, `/api/v2/spaces/${id}/pages`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const key = await serverSpaceKey(conn, { spaceId, spaceKey });
|
|
156
|
+
if (!key) return null;
|
|
157
|
+
const pages = await paginate(conn, '/rest/api/content', {
|
|
158
|
+
spaceKey: key,
|
|
159
|
+
type: 'page',
|
|
160
|
+
expand: 'version,history',
|
|
161
|
+
limit: 100,
|
|
162
|
+
});
|
|
163
|
+
return pages.map(serverPage);
|
|
63
164
|
}
|
|
64
165
|
|
|
65
|
-
export async function searchPages(cql) {
|
|
66
|
-
|
|
166
|
+
export async function searchPages(connection, cql) {
|
|
167
|
+
const conn = await connect(connection);
|
|
168
|
+
const result = await request(conn, apiUrl(conn, '/rest/api/search'), { cql });
|
|
169
|
+
return { ...result, totalSize: result.totalSize ?? result.totalCount };
|
|
67
170
|
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { mkdir, rm } from 'node:fs/promises';
|
|
4
|
+
import { configPath, validateGraphId } from '../config/store.js';
|
|
5
|
+
import { createProtectedCache } from './graph-cache.js';
|
|
6
|
+
|
|
7
|
+
export const GRAPH_SCOPES = ['User.Read', 'Chat.Read', 'Mail.Read', 'Sites.Read.All'];
|
|
8
|
+
export const SHARED_MAIL_SCOPE = 'Mail.Read.Shared';
|
|
9
|
+
export const CALENDAR_SCOPE = 'Calendars.Read.Shared';
|
|
10
|
+
export const AVAILABILITY_SCOPE = 'Calendars.ReadBasic';
|
|
11
|
+
|
|
12
|
+
function requestedScopes({ sharedMail = false, calendars = false, availability = false, all = false } = {}) {
|
|
13
|
+
return [...GRAPH_SCOPES,
|
|
14
|
+
...(sharedMail || all ? [SHARED_MAIL_SCOPE] : []),
|
|
15
|
+
...(calendars || all ? [CALENDAR_SCOPE, AVAILABILITY_SCOPE] : availability ? [AVAILABILITY_SCOPE] : []),
|
|
16
|
+
];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function graphIdentity(connection = {}) {
|
|
20
|
+
const tenantId = connection.tenantId?.trim().toLowerCase();
|
|
21
|
+
const clientId = connection.clientId?.trim().toLowerCase();
|
|
22
|
+
if (validateGraphId(tenantId) || validateGraphId(clientId)) {
|
|
23
|
+
throw new Error('The Microsoft Graph connection needs a valid tenant ID and client ID. Run: dev graph setup');
|
|
24
|
+
}
|
|
25
|
+
return { tenantId, clientId };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The token cache is partitioned by tenant and client ID, so connections for the same application share one sign-in.
|
|
29
|
+
async function createApplication(connection) {
|
|
30
|
+
const { tenantId, clientId } = graphIdentity(connection);
|
|
31
|
+
const { PublicClientApplication } = await import('@azure/msal-node');
|
|
32
|
+
const identity = createHash('sha256').update(`${tenantId}:${clientId}`).digest('hex');
|
|
33
|
+
const directory = join(dirname(configPath()), 'graph-auth');
|
|
34
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
35
|
+
// Caches written by the earlier msal-node-extensions storage cannot be read by this one.
|
|
36
|
+
await rm(join(directory, `${identity}.bin`), { force: true });
|
|
37
|
+
await rm(join(directory, `${identity}.bin.lockfile`), { force: true });
|
|
38
|
+
return new PublicClientApplication({
|
|
39
|
+
auth: { clientId, authority: `https://login.microsoftonline.com/${tenantId}` },
|
|
40
|
+
cache: { cachePlugin: createProtectedCache({ path: join(directory, `${identity}.cache`), identity }) },
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createGraphAuth({ connection, applicationFactory = () => createApplication(connection), browserOpener } = {}) {
|
|
45
|
+
let application;
|
|
46
|
+
const app = () => application ||= applicationFactory();
|
|
47
|
+
|
|
48
|
+
async function account() {
|
|
49
|
+
const accounts = await (await app()).getTokenCache().getAllAccounts();
|
|
50
|
+
if (accounts.length !== 1) throw new Error('Sign in to Microsoft Graph first. Run: dev graph login');
|
|
51
|
+
return accounts[0];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
async login(options = {}) {
|
|
56
|
+
const client = await app();
|
|
57
|
+
const openBrowser = browserOpener || (async (url) => {
|
|
58
|
+
const { default: open } = await import('open');
|
|
59
|
+
await open(url);
|
|
60
|
+
});
|
|
61
|
+
const scopes = requestedScopes(options);
|
|
62
|
+
const result = await client.acquireTokenInteractive({
|
|
63
|
+
scopes,
|
|
64
|
+
prompt: 'select_account',
|
|
65
|
+
openBrowser,
|
|
66
|
+
successTemplate: 'Return to the terminal to check the sign-in result. You can close this window.',
|
|
67
|
+
errorTemplate: 'Sign-in failed. Return to the terminal for details.',
|
|
68
|
+
});
|
|
69
|
+
if (!result?.account) throw new Error('Microsoft did not return an account. Run: dev graph login');
|
|
70
|
+
const cache = client.getTokenCache();
|
|
71
|
+
// A cache belongs to one CLI identity so silent requests never pick an arbitrary account.
|
|
72
|
+
for (const cached of await cache.getAllAccounts()) {
|
|
73
|
+
if (cached.homeAccountId !== result.account.homeAccountId) await cache.removeAccount(cached);
|
|
74
|
+
}
|
|
75
|
+
return { username: result.account.username, name: result.account.name || null, tenantId: result.account.tenantId };
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
async token({ sharedMail = false, calendars = false, availability = false } = {}) {
|
|
79
|
+
const client = await app();
|
|
80
|
+
const current = await account();
|
|
81
|
+
const scopes = requestedScopes({ sharedMail, calendars, availability });
|
|
82
|
+
try {
|
|
83
|
+
const result = await client.acquireTokenSilent({
|
|
84
|
+
account: current,
|
|
85
|
+
scopes,
|
|
86
|
+
});
|
|
87
|
+
if (!result?.accessToken) throw new Error('No access token returned.');
|
|
88
|
+
return result.accessToken;
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if (['interaction_required', 'consent_required', 'login_required', 'invalid_grant', 'no_tokens_found'].includes(err.errorCode)
|
|
91
|
+
|| err.name === 'InteractionRequiredAuthError') {
|
|
92
|
+
const flags = `${sharedMail ? ' --shared-mail' : ''}${calendars || availability ? ' --calendars' : ''}`;
|
|
93
|
+
throw new Error(`Microsoft sign-in expired or needs consent for ${scopes.join(', ')}. Check delegated permissions, then run: dev graph login${flags}`);
|
|
94
|
+
}
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
async status() {
|
|
100
|
+
const current = await account();
|
|
101
|
+
return { username: current.username, name: current.name || null, tenantId: current.tenantId, cached: true };
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
async logout() {
|
|
105
|
+
const cache = (await app()).getTokenCache();
|
|
106
|
+
for (const cached of await cache.getAllAccounts()) await cache.removeAccount(cached);
|
|
107
|
+
return { signedOut: true, message: 'Local Graph account removed. Browser sessions and server-side grants are unchanged.' };
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|