@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # dev
2
2
 
3
- A terminal-first CLI for working with Bitbucket PRs, Jira tickets, and Confluence pages. Skip the browser, stay in your terminal.
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. On first run, it'll walk you through connecting your Bitbucket, Jira, and Confluence accounts.
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 # re-run setup
34
- dev config show # current config (tokens masked)
35
- dev config set <key> <value> # set a single value
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. You'll need:
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
- - **Bitbucket**: workspace, default repo, email, app password
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.1",
4
- "description": "CLI for Bitbucket PRs, Jira tickets, Confluence pages, and Service Desk tickets",
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
  }
@@ -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
- const email = get('bitbucket.email');
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 workspace = get('bitbucket.workspace');
13
- const repo = repoOverride || get('bitbucket.defaultRepo');
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
- import { get } from '../config/store.js';
1
+ const deployments = new Map();
2
2
 
3
- function apiUrl(path) {
4
- return new URL(`/wiki${path}`, get('confluence.baseUrl'));
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
- function authHeader() {
8
- const email = get('confluence.email');
9
- const token = get('confluence.token');
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 !== new URL(get('confluence.baseUrl')).origin) {
15
- throw new Error('Confluence pagination points to a different site. Check confluence.baseUrl.');
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 paginateV2(path, params = {}) {
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 = new URL(data._links.next, 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
- export async function listSpaces() {
54
- return paginateV2('/api/v2/spaces');
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
- export async function getPage(id) {
58
- return request(apiUrl(`/api/v2/pages/${id}`), { 'body-format': 'storage' });
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
- export async function listSpacePages(spaceId) {
62
- return paginateV2(`/api/v2/spaces/${spaceId}/pages`);
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
- return request(apiUrl('/rest/api/search'), { cql });
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
+ }