@falldownthesystem/devtool 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/clients/confluence.js +171 -67
- package/src/commands/confluence.js +14 -19
- package/src/config/store.js +3 -3
package/README.md
CHANGED
|
@@ -53,7 +53,7 @@ Stored via the `conf` package in your OS config directory. You'll need:
|
|
|
53
53
|
|
|
54
54
|
- **Bitbucket**: workspace, default repo, email, app password
|
|
55
55
|
- **Jira**: base URL, API token, default project
|
|
56
|
-
- **Confluence**:
|
|
56
|
+
- **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.
|
|
57
57
|
|
|
58
58
|
## License
|
|
59
59
|
|
package/package.json
CHANGED
|
@@ -1,67 +1,171 @@
|
|
|
1
|
-
import { get } from '../config/store.js';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
function
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
1
|
+
import { get } from '../config/store.js';
|
|
2
|
+
|
|
3
|
+
const deployments = new Map();
|
|
4
|
+
|
|
5
|
+
// Custom domains hide the atlassian.net hostname, so anything else is probed:
|
|
6
|
+
// Atlassian's edge answers tenant_info anonymously, Server/Data Center does not.
|
|
7
|
+
async function detectDeployment(origin) {
|
|
8
|
+
if (/\.atlassian\.net$/i.test(new URL(origin).hostname)) return 'cloud';
|
|
9
|
+
try {
|
|
10
|
+
const res = await fetch(new URL('/_edge/tenant_info', origin), {
|
|
11
|
+
headers: { 'Accept': 'application/json' },
|
|
12
|
+
});
|
|
13
|
+
if (res.ok && (await res.json()).cloudId) return 'cloud';
|
|
14
|
+
} catch {
|
|
15
|
+
// An unreachable or non-JSON probe means no Atlassian edge; the real request reports connection errors.
|
|
16
|
+
}
|
|
17
|
+
return 'server';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function connect() {
|
|
21
|
+
const base = new URL(get('confluence.baseUrl'));
|
|
22
|
+
if (!deployments.has(base.origin)) deployments.set(base.origin, detectDeployment(base.origin));
|
|
23
|
+
const kind = await deployments.get(base.origin);
|
|
24
|
+
// Cloud always serves Confluence under /wiki; Server/Data Center keeps its own context path.
|
|
25
|
+
const path = kind === 'cloud' ? '/wiki' : base.pathname.replace(/\/+$/, '');
|
|
26
|
+
return { kind, origin: base.origin, path };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function apiUrl(conn, path) {
|
|
30
|
+
return new URL(`${conn.origin}${conn.path}${path}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Cloud v2 links are origin-relative, Server/Data Center links are relative to the context path.
|
|
34
|
+
function nextUrl(conn, link, current) {
|
|
35
|
+
const contextRelative = conn.kind === 'server'
|
|
36
|
+
&& link.startsWith('/') && !link.startsWith('//')
|
|
37
|
+
&& !(conn.path && link.startsWith(`${conn.path}/`));
|
|
38
|
+
return contextRelative ? apiUrl(conn, link) : new URL(link, current);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Server/Data Center personal access tokens use Bearer auth; a username switches to Basic.
|
|
42
|
+
function authHeader(conn) {
|
|
43
|
+
const email = get('confluence.email');
|
|
44
|
+
const token = get('confluence.token');
|
|
45
|
+
if (conn.kind === 'server' && !email) return `Bearer ${token}`;
|
|
46
|
+
return 'Basic ' + Buffer.from(`${email}:${token}`).toString('base64');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function request(conn, url, params = {}) {
|
|
50
|
+
if (url.origin !== conn.origin) {
|
|
51
|
+
throw new Error('Confluence pagination points to a different site. Check confluence.baseUrl.');
|
|
52
|
+
}
|
|
53
|
+
for (const [k, v] of Object.entries(params)) {
|
|
54
|
+
if (v !== undefined && v !== null) url.searchParams.set(k, v);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const res = await fetch(url, {
|
|
58
|
+
headers: {
|
|
59
|
+
'Authorization': authHeader(conn),
|
|
60
|
+
'Accept': 'application/json',
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (!res.ok) {
|
|
65
|
+
const err = new Error(`Confluence API error: ${res.status} ${res.statusText}`);
|
|
66
|
+
err.status = res.status;
|
|
67
|
+
err.url = url.toString();
|
|
68
|
+
throw err;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Server/Data Center answers rejected credentials with a login page instead of a 401.
|
|
72
|
+
if (!res.headers.get('content-type')?.includes('json')) {
|
|
73
|
+
const err = new Error('Confluence returned a non-JSON response. Check confluence.baseUrl and confluence.token.');
|
|
74
|
+
err.url = url.toString();
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return res.json();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function paginate(conn, path, params = {}) {
|
|
82
|
+
const results = [];
|
|
83
|
+
let url = apiUrl(conn, path);
|
|
84
|
+
let data = await request(conn, url, params);
|
|
85
|
+
results.push(...(data.results || []));
|
|
86
|
+
|
|
87
|
+
while (data._links?.next) {
|
|
88
|
+
url = nextUrl(conn, data._links.next, url);
|
|
89
|
+
data = await request(conn, url);
|
|
90
|
+
results.push(...(data.results || []));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return results;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Server/Data Center v1 content is reshaped to the Cloud v2 fields the formatters read.
|
|
97
|
+
function serverSpace(space) {
|
|
98
|
+
return {
|
|
99
|
+
id: String(space.id),
|
|
100
|
+
key: space.key,
|
|
101
|
+
name: space.name,
|
|
102
|
+
type: space.type,
|
|
103
|
+
status: space.status,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function serverPage(page) {
|
|
108
|
+
return {
|
|
109
|
+
id: page.id,
|
|
110
|
+
title: page.title,
|
|
111
|
+
status: page.status,
|
|
112
|
+
spaceId: page.space?.id != null ? String(page.space.id) : undefined,
|
|
113
|
+
createdAt: page.history?.createdDate,
|
|
114
|
+
version: page.version ? { number: page.version.number } : undefined,
|
|
115
|
+
body: page.body,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// The content listing silently returns nothing for an unknown space key, so the space is looked up first.
|
|
120
|
+
async function serverSpaceKey(conn, { spaceId, spaceKey }) {
|
|
121
|
+
if (!spaceKey) return (await listSpaces()).find((s) => s.id === String(spaceId))?.key;
|
|
122
|
+
try {
|
|
123
|
+
return (await request(conn, apiUrl(conn, `/rest/api/space/${encodeURIComponent(spaceKey)}`))).key;
|
|
124
|
+
} catch (err) {
|
|
125
|
+
if (err.status === 404) return undefined;
|
|
126
|
+
throw err;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function listSpaces() {
|
|
131
|
+
const conn = await connect();
|
|
132
|
+
if (conn.kind === 'cloud') return paginate(conn, '/api/v2/spaces');
|
|
133
|
+
return (await paginate(conn, '/rest/api/space', { limit: 100 })).map(serverSpace);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function getPage(id) {
|
|
137
|
+
const conn = await connect();
|
|
138
|
+
if (conn.kind === 'cloud') {
|
|
139
|
+
return request(conn, apiUrl(conn, `/api/v2/pages/${id}`), { 'body-format': 'storage' });
|
|
140
|
+
}
|
|
141
|
+
return serverPage(await request(conn, apiUrl(conn, `/rest/api/content/${id}`), {
|
|
142
|
+
expand: 'body.storage,version,space,history',
|
|
143
|
+
}));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Cloud lists pages by space ID and Server/Data Center by space key; returns null for an unknown space.
|
|
147
|
+
export async function listSpacePages({ spaceId, spaceKey }) {
|
|
148
|
+
const conn = await connect();
|
|
149
|
+
if (conn.kind === 'cloud') {
|
|
150
|
+
const id = spaceId
|
|
151
|
+
?? (await request(conn, apiUrl(conn, '/api/v2/spaces'), { keys: spaceKey })).results?.[0]?.id;
|
|
152
|
+
if (!id) return null;
|
|
153
|
+
return paginate(conn, `/api/v2/spaces/${id}/pages`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const key = await serverSpaceKey(conn, { spaceId, spaceKey });
|
|
157
|
+
if (!key) return null;
|
|
158
|
+
const pages = await paginate(conn, '/rest/api/content', {
|
|
159
|
+
spaceKey: key,
|
|
160
|
+
type: 'page',
|
|
161
|
+
expand: 'version,history',
|
|
162
|
+
limit: 100,
|
|
163
|
+
});
|
|
164
|
+
return pages.map(serverPage);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function searchPages(cql) {
|
|
168
|
+
const conn = await connect();
|
|
169
|
+
const result = await request(conn, apiUrl(conn, '/rest/api/search'), { cql });
|
|
170
|
+
return { ...result, totalSize: result.totalSize ?? result.totalCount };
|
|
171
|
+
}
|
|
@@ -57,30 +57,25 @@ confluence.command('pages')
|
|
|
57
57
|
.alias('ls')
|
|
58
58
|
.description('List pages in a space')
|
|
59
59
|
.option('-s, --space <key>', 'Space key (overrides default)')
|
|
60
|
-
.option('--space-id <id>', 'Space ID (
|
|
60
|
+
.option('--space-id <id>', 'Space ID (instead of a key)')
|
|
61
61
|
.action(async function (opts) {
|
|
62
62
|
const mode = getOutputMode(this);
|
|
63
63
|
const s = mode === 'rich' ? spinner('Fetching pages...').start() : null;
|
|
64
64
|
try {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
process.exitCode = 1;
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
spaceId = match.id;
|
|
65
|
+
const spaceId = opts.spaceId;
|
|
66
|
+
const spaceKey = spaceId ? undefined : opts.space || get('confluence.defaultSpace');
|
|
67
|
+
if (!spaceId && !spaceKey) {
|
|
68
|
+
if (s) s.error({ text: 'No space specified' });
|
|
69
|
+
console.error('Specify --space <key>, --space-id <id>, or set confluence.defaultSpace');
|
|
70
|
+
process.exitCode = 1;
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const pages = await listSpacePages({ spaceId, spaceKey });
|
|
74
|
+
if (!pages) {
|
|
75
|
+
if (s) s.error({ text: `Space "${spaceId || spaceKey}" not found` });
|
|
76
|
+
process.exitCode = 1;
|
|
77
|
+
return;
|
|
82
78
|
}
|
|
83
|
-
const pages = await listSpacePages(spaceId);
|
|
84
79
|
if (s) s.success({ text: `${pages.length} page(s) found` });
|
|
85
80
|
if (mode === 'json') outputJson(curatePagesJson(pages));
|
|
86
81
|
else if (mode === 'plain') plainPages(pages);
|
package/src/config/store.js
CHANGED
|
@@ -136,17 +136,17 @@ export async function runSetup() {
|
|
|
136
136
|
|
|
137
137
|
const confluence = await p.group({
|
|
138
138
|
baseUrl: () => p.text({
|
|
139
|
-
message: 'Confluence Cloud
|
|
139
|
+
message: 'Confluence base URL (Cloud or Server/Data Center)',
|
|
140
140
|
placeholder: 'https://company.atlassian.net',
|
|
141
141
|
initialValue: config.get('confluence.baseUrl') || '',
|
|
142
142
|
}),
|
|
143
143
|
email: () => p.text({
|
|
144
|
-
message: 'Confluence account email',
|
|
144
|
+
message: 'Confluence account email (Server/Data Center: username, or empty for a personal access token)',
|
|
145
145
|
placeholder: 'you@company.com',
|
|
146
146
|
initialValue: config.get('confluence.email') || '',
|
|
147
147
|
}),
|
|
148
148
|
token: () => p.password({
|
|
149
|
-
message: 'Confluence API token',
|
|
149
|
+
message: 'Confluence API token (Server/Data Center: personal access token or password)',
|
|
150
150
|
}),
|
|
151
151
|
defaultSpace: () => p.text({
|
|
152
152
|
message: 'Confluence default space key',
|