@aholbreich/agent-skills 0.9.0 → 1.0.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/CHANGELOG.md +14 -0
- package/README.md +51 -1
- package/bin/vendor.js +24 -0
- package/package.json +6 -3
- package/skills/bitbucket-browser-fetch/scripts/atlassian-browser.js +261 -0
- package/skills/bitbucket-browser-fetch/scripts/bitbucket-browser-fetch.js +16 -197
- package/skills/confluence-browser-fetch/scripts/atlassian-browser.js +261 -0
- package/skills/confluence-browser-fetch/scripts/confluence-browser-fetch.js +15 -210
- package/skills/confluence-update/scripts/atlassian-browser.js +261 -0
- package/skills/confluence-update/scripts/confluence-update.js +31 -224
- package/skills/jira-browser-fetch/scripts/atlassian-browser.js +261 -0
- package/skills/jira-browser-fetch/scripts/jira-browser-fetch.js +27 -230
- package/skills/jira-update/SKILL.md +121 -0
- package/skills/jira-update/references/distribution.md +5 -0
- package/skills/jira-update/references/usage.md +75 -0
- package/skills/jira-update/scripts/atlassian-browser.js +261 -0
- package/skills/jira-update/scripts/jira-update.js +404 -0
- package/skills/jira-update/scripts/lib.js +283 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
(empty)
|
|
6
|
+
|
|
7
|
+
## 0.10.0 - 2026-05-08
|
|
8
|
+
|
|
9
|
+
Added:
|
|
10
|
+
|
|
11
|
+
- New `jira-update` skill for dry-run-first Jira Cloud writes through an authenticated browser session: `create`, `comment`, `transition`, `update-fields`, and `link` commands. Markdown-to-ADF conversion by default; ADF passthrough as escape hatch.
|
|
12
|
+
|
|
13
|
+
Changed:
|
|
14
|
+
|
|
15
|
+
- Extracted browser/CDP/cookie helpers from all four existing skills into a single source-of-truth `lib/atlassian-browser.js`. Vendored at pack time into each `skills/*/scripts/atlassian-browser.js` so each skill folder remains self-contained on disk. Eliminates ~870 lines of duplicated code across the bundle.
|
|
16
|
+
|
|
17
|
+
## 0.9.0 - 2026-05-07
|
|
18
|
+
|
|
5
19
|
Added:
|
|
6
20
|
|
|
7
21
|
- New `bitbucket-browser-fetch` skill for browser-authenticated Bitbucket Cloud project repository inventory, SSH/HTTPS clone URL lists, Markdown summaries, and safe clone helper scripts.
|
package/README.md
CHANGED
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
Handcrafted [Agent Skills](https://agentskills.io/) for developer and LLM-wiki workflows. The package is intentionally a pure skills package with broad compatibility across Pi, Claude Code, Codex, OpenClaw/generic `.agents` setups, and other Agent Skills-compatible harnesses.
|
|
4
4
|
|
|
5
|
-
This repository is a pure skills package. It currently contains browser-authenticated Atlassian fetch
|
|
5
|
+
This repository is a pure skills package. It currently contains browser-authenticated Atlassian fetch and update tools (Jira read+write, Confluence read+write, Bitbucket read) that work well when Jira/Confluence/Bitbucket API-token authentication is unavailable because an organization uses Microsoft/SSO.
|
|
6
6
|
|
|
7
7
|
## Skills
|
|
8
8
|
|
|
9
9
|
| Skill | Purpose |
|
|
10
10
|
|---|---|
|
|
11
11
|
| [`jira-browser-fetch`](skills/jira-browser-fetch/) | Fetch Jira issue JSON, rendered HTML/XML, linked/referenced issues, Jira Software board backlogs, JQL result sets, and attachments through an authenticated Chrome session. |
|
|
12
|
+
| [`jira-update`](skills/jira-update/) | Dry-run-first Jira Cloud writes through an authenticated browser session: create issues, add comments, transition workflows, update fields, and link issues. Markdown-to-ADF conversion by default; ADF passthrough as escape hatch. |
|
|
12
13
|
| [`confluence-browser-fetch`](skills/confluence-browser-fetch/) | Fetch Confluence page JSON, storage/view HTML, browser HTML, descendants, CQL result sets, and attachments through an authenticated Chrome session. |
|
|
13
14
|
| [`confluence-update`](skills/confluence-update/) | Dry-run-first Confluence page updates, agent-owned block replacement, Markdown-to-storage conversion, and page creation through an authenticated browser session. |
|
|
14
15
|
| [`bitbucket-browser-fetch`](skills/bitbucket-browser-fetch/) | Fetch Bitbucket Cloud project repository inventories and clone URL lists through an authenticated browser session. |
|
|
@@ -176,6 +177,7 @@ If installed globally via npm, the package exposes:
|
|
|
176
177
|
```bash
|
|
177
178
|
agent-skills
|
|
178
179
|
jira-browser-fetch
|
|
180
|
+
jira-update
|
|
179
181
|
confluence-browser-fetch
|
|
180
182
|
confluence-update
|
|
181
183
|
bitbucket-browser-fetch
|
|
@@ -281,6 +283,54 @@ Example user requests that should invoke this skill:
|
|
|
281
283
|
- "Pull my assigned Jira issues without asking me for an API token."
|
|
282
284
|
- "Use this JQL and store the raw Jira evidence under the wiki raw folder."
|
|
283
285
|
|
|
286
|
+
## Jira update examples
|
|
287
|
+
|
|
288
|
+
Dry-run an issue creation from a manifest:
|
|
289
|
+
|
|
290
|
+
```bash
|
|
291
|
+
jira-update create \
|
|
292
|
+
--server https://example.atlassian.net \
|
|
293
|
+
--file ./new-bug.json
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Apply after review:
|
|
297
|
+
|
|
298
|
+
```bash
|
|
299
|
+
jira-update create \
|
|
300
|
+
--server https://example.atlassian.net \
|
|
301
|
+
--file ./new-bug.json \
|
|
302
|
+
--apply
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
Add a comment from Markdown:
|
|
306
|
+
|
|
307
|
+
```bash
|
|
308
|
+
jira-update comment PROJ-123 \
|
|
309
|
+
--server https://example.atlassian.net \
|
|
310
|
+
--file ./reply.md \
|
|
311
|
+
--apply
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
Transition with a comment:
|
|
315
|
+
|
|
316
|
+
```bash
|
|
317
|
+
jira-update transition PROJ-123 \
|
|
318
|
+
--server https://example.atlassian.net \
|
|
319
|
+
--to "In Progress" \
|
|
320
|
+
--comment-file ./status.md \
|
|
321
|
+
--apply
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
Link two issues:
|
|
325
|
+
|
|
326
|
+
```bash
|
|
327
|
+
jira-update link PROJ-123 \
|
|
328
|
+
--server https://example.atlassian.net \
|
|
329
|
+
--to PROJ-456 \
|
|
330
|
+
--type blocks \
|
|
331
|
+
--apply
|
|
332
|
+
```
|
|
333
|
+
|
|
284
334
|
## Confluence examples
|
|
285
335
|
|
|
286
336
|
Fetch one page by URL:
|
package/bin/vendor.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
const repoRoot = path.resolve(__dirname, '..');
|
|
8
|
+
const source = path.join(repoRoot, 'lib/atlassian-browser.js');
|
|
9
|
+
const skillsDir = path.join(repoRoot, 'skills');
|
|
10
|
+
|
|
11
|
+
if (!fs.existsSync(source)) {
|
|
12
|
+
console.error(`vendor: source not found at ${source}`);
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const content = fs.readFileSync(source);
|
|
17
|
+
const skills = fs.readdirSync(skillsDir, { withFileTypes: true }).filter(d => d.isDirectory());
|
|
18
|
+
for (const skill of skills) {
|
|
19
|
+
const scriptsDir = path.join(skillsDir, skill.name, 'scripts');
|
|
20
|
+
if (!fs.existsSync(scriptsDir)) continue;
|
|
21
|
+
const dest = path.join(scriptsDir, 'atlassian-browser.js');
|
|
22
|
+
fs.writeFileSync(dest, content);
|
|
23
|
+
console.log(`vendored -> ${path.relative(repoRoot, dest)}`);
|
|
24
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aholbreich/agent-skills",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Handcrafted Agent Skills for browser-authenticated Jira and Confluence ingestion, LLM wiki workflows, and developer automation.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -39,10 +39,13 @@
|
|
|
39
39
|
"jira-browser-fetch": "skills/jira-browser-fetch/scripts/jira-browser-fetch.js",
|
|
40
40
|
"confluence-browser-fetch": "skills/confluence-browser-fetch/scripts/confluence-browser-fetch.js",
|
|
41
41
|
"confluence-update": "skills/confluence-update/scripts/confluence-update.js",
|
|
42
|
-
"bitbucket-browser-fetch": "skills/bitbucket-browser-fetch/scripts/bitbucket-browser-fetch.js"
|
|
42
|
+
"bitbucket-browser-fetch": "skills/bitbucket-browser-fetch/scripts/bitbucket-browser-fetch.js",
|
|
43
|
+
"jira-update": "skills/jira-update/scripts/jira-update.js"
|
|
43
44
|
},
|
|
44
45
|
"scripts": {
|
|
45
|
-
"
|
|
46
|
+
"vendor": "node bin/vendor.js",
|
|
47
|
+
"check": "node --check bin/agent-skills.js && node --check bin/vendor.js && node --check lib/atlassian-browser.js && npm run vendor && node --check skills/jira-browser-fetch/scripts/jira-browser-fetch.js && node --check skills/jira-browser-fetch/scripts/lib.js && node --check skills/jira-browser-fetch/scripts/atlassian-browser.js && node --check skills/confluence-browser-fetch/scripts/confluence-browser-fetch.js && node --check skills/confluence-browser-fetch/scripts/lib.js && node --check skills/confluence-browser-fetch/scripts/atlassian-browser.js && node --check skills/confluence-update/scripts/confluence-update.js && node --check skills/confluence-update/scripts/lib.js && node --check skills/confluence-update/scripts/atlassian-browser.js && node --check skills/bitbucket-browser-fetch/scripts/bitbucket-browser-fetch.js && node --check skills/bitbucket-browser-fetch/scripts/lib.js && node --check skills/bitbucket-browser-fetch/scripts/atlassian-browser.js && node --check skills/jira-update/scripts/jira-update.js && node --check skills/jira-update/scripts/lib.js && node --check skills/jira-update/scripts/atlassian-browser.js",
|
|
48
|
+
"pretest": "npm run vendor",
|
|
46
49
|
"test": "node --test",
|
|
47
50
|
"ci": "npm run check && npm test && npm pack --dry-run",
|
|
48
51
|
"pack:dry": "npm pack --dry-run",
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { spawn } = require('node:child_process');
|
|
6
|
+
|
|
7
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
8
|
+
|
|
9
|
+
function isExecutable(file) {
|
|
10
|
+
try { fs.accessSync(file, fs.constants.X_OK); return true; } catch { return false; }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function resolveBrowserCandidate(candidate) {
|
|
14
|
+
if (!candidate) return null;
|
|
15
|
+
if (candidate.includes(path.sep)) return isExecutable(candidate) ? candidate : null;
|
|
16
|
+
for (const dir of String(process.env.PATH || '').split(path.delimiter)) {
|
|
17
|
+
if (!dir) continue;
|
|
18
|
+
const full = path.join(dir, candidate);
|
|
19
|
+
if (isExecutable(full)) return full;
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function findBrowserExecutable() {
|
|
25
|
+
const candidates = [
|
|
26
|
+
process.env.CHROME,
|
|
27
|
+
process.env.CHROMIUM,
|
|
28
|
+
'google-chrome',
|
|
29
|
+
'google-chrome-stable',
|
|
30
|
+
'chromium',
|
|
31
|
+
'chromium-browser',
|
|
32
|
+
'brave-browser',
|
|
33
|
+
'brave',
|
|
34
|
+
'microsoft-edge',
|
|
35
|
+
'microsoft-edge-stable',
|
|
36
|
+
'vivaldi',
|
|
37
|
+
'vivaldi-stable',
|
|
38
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
39
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
40
|
+
'/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
|
|
41
|
+
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
42
|
+
'/Applications/Vivaldi.app/Contents/MacOS/Vivaldi',
|
|
43
|
+
];
|
|
44
|
+
for (const candidate of candidates) {
|
|
45
|
+
const resolved = resolveBrowserCandidate(candidate);
|
|
46
|
+
if (resolved) return resolved;
|
|
47
|
+
}
|
|
48
|
+
throw new Error('Could not find a Chromium-compatible browser. Install Chrome/Chromium/Brave/Edge/Vivaldi or set CHROME=/path/to/browser.');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function connectCdp(wsUrl) {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
const ws = new WebSocket(wsUrl);
|
|
54
|
+
let id = 0;
|
|
55
|
+
const pending = new Map();
|
|
56
|
+
const failTimer = setTimeout(() => reject(new Error('CDP websocket timeout')), 10000);
|
|
57
|
+
|
|
58
|
+
ws.addEventListener('open', () => {
|
|
59
|
+
clearTimeout(failTimer);
|
|
60
|
+
resolve({
|
|
61
|
+
send(method, params = {}) {
|
|
62
|
+
return new Promise((res, rej) => {
|
|
63
|
+
const msgId = ++id;
|
|
64
|
+
pending.set(msgId, { res, rej });
|
|
65
|
+
ws.send(JSON.stringify({ id: msgId, method, params }));
|
|
66
|
+
});
|
|
67
|
+
},
|
|
68
|
+
close() { try { ws.close(); } catch {} },
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
ws.addEventListener('message', ev => {
|
|
73
|
+
let data = ev.data;
|
|
74
|
+
if (typeof data !== 'string') data = Buffer.from(data).toString('utf8');
|
|
75
|
+
const msg = JSON.parse(data);
|
|
76
|
+
if (!msg.id || !pending.has(msg.id)) return;
|
|
77
|
+
const { res, rej } = pending.get(msg.id);
|
|
78
|
+
pending.delete(msg.id);
|
|
79
|
+
if (msg.error) rej(new Error(`${msg.error.message || 'CDP error'} ${JSON.stringify(msg.error)}`));
|
|
80
|
+
else res(msg.result);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
ws.addEventListener('error', err => reject(err));
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function createBrowserSession({ port, profileDir, waitSec, serverHost, verifySession, cookieUrls, userAgent }) {
|
|
88
|
+
if (!serverHost) throw new Error('createBrowserSession requires serverHost');
|
|
89
|
+
if (typeof verifySession !== 'function') throw new Error('createBrowserSession requires verifySession callback');
|
|
90
|
+
const ua = userAgent || 'agent-skills/1.0';
|
|
91
|
+
|
|
92
|
+
async function endpoint(pathname) {
|
|
93
|
+
const res = await fetch(`http://127.0.0.1:${port}${pathname}`);
|
|
94
|
+
if (!res.ok) throw new Error(`DevTools HTTP ${res.status} for ${pathname}`);
|
|
95
|
+
return res.json();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function devtoolsReady() {
|
|
99
|
+
try { await endpoint('/json/version'); return true; } catch { return false; }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function waitDevtools() {
|
|
103
|
+
for (let i = 0; i < 80; i++) {
|
|
104
|
+
if (await devtoolsReady()) return;
|
|
105
|
+
await sleep(250);
|
|
106
|
+
}
|
|
107
|
+
throw new Error('Chrome DevTools endpoint did not start');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function openDevtoolsTab(url) {
|
|
111
|
+
if (!url) return false;
|
|
112
|
+
const endpointUrl = `http://127.0.0.1:${port}/json/new?${encodeURIComponent(url)}`;
|
|
113
|
+
for (const init of [{ method: 'PUT' }, {}]) {
|
|
114
|
+
try {
|
|
115
|
+
const res = await fetch(endpointUrl, init);
|
|
116
|
+
if (res.ok) { await sleep(500); return true; }
|
|
117
|
+
} catch {}
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function hasDevtoolsTabForHost(url, pathPrefix) {
|
|
123
|
+
if (!url) return false;
|
|
124
|
+
const host = new URL(url).host;
|
|
125
|
+
const list = await endpoint('/json/list');
|
|
126
|
+
return list.some(t => t.type === 'page' && t.url && (() => {
|
|
127
|
+
try {
|
|
128
|
+
const tabUrl = new URL(t.url);
|
|
129
|
+
if (tabUrl.host !== host) return false;
|
|
130
|
+
if (pathPrefix && !tabUrl.pathname.startsWith(pathPrefix)) return false;
|
|
131
|
+
return true;
|
|
132
|
+
} catch { return false; }
|
|
133
|
+
})());
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function launchChrome(url) {
|
|
137
|
+
const browser = findBrowserExecutable();
|
|
138
|
+
const args = [
|
|
139
|
+
`--remote-debugging-port=${port}`,
|
|
140
|
+
'--remote-debugging-address=127.0.0.1',
|
|
141
|
+
'--remote-allow-origins=*',
|
|
142
|
+
`--user-data-dir=${profileDir}`,
|
|
143
|
+
'--no-first-run',
|
|
144
|
+
'--no-default-browser-check',
|
|
145
|
+
url,
|
|
146
|
+
];
|
|
147
|
+
console.log(`Launching browser: ${browser}`);
|
|
148
|
+
const child = spawn(browser, args, { detached: true, stdio: 'ignore' });
|
|
149
|
+
child.on('error', err => console.error(`Failed to launch browser ${browser}: ${err.message}`));
|
|
150
|
+
child.unref();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function ensureBrowser(openUrl, { tabPathPrefix } = {}) {
|
|
154
|
+
if (!(await devtoolsReady())) {
|
|
155
|
+
console.log(`Opening Chromium-compatible browser with reusable profile: ${profileDir}`);
|
|
156
|
+
launchChrome(openUrl);
|
|
157
|
+
} else {
|
|
158
|
+
console.log(`Reusing Chrome DevTools on port ${port}`);
|
|
159
|
+
if (openUrl) {
|
|
160
|
+
const hasTab = await hasDevtoolsTabForHost(openUrl, tabPathPrefix);
|
|
161
|
+
if (hasTab) {
|
|
162
|
+
console.log(`Found existing tab for ${new URL(openUrl).host}; not opening another tab.`);
|
|
163
|
+
} else {
|
|
164
|
+
const opened = await openDevtoolsTab(openUrl);
|
|
165
|
+
if (opened) console.log(`Opened target URL in reused browser: ${openUrl}`);
|
|
166
|
+
else console.warn('Could not open target URL through DevTools; continuing with existing tabs.');
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
await waitDevtools();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function getPageWsUrl() {
|
|
174
|
+
const list = await endpoint('/json/list');
|
|
175
|
+
const pages = list.filter(t => t.type === 'page' && t.webSocketDebuggerUrl);
|
|
176
|
+
const preferred = pages.find(t => (t.url || '').includes(serverHost)) || pages[0];
|
|
177
|
+
return preferred && preferred.webSocketDebuggerUrl;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function getCookieHeader() {
|
|
181
|
+
const wsUrl = await getPageWsUrl();
|
|
182
|
+
if (!wsUrl) return '';
|
|
183
|
+
const cdp = await connectCdp(wsUrl);
|
|
184
|
+
try {
|
|
185
|
+
await cdp.send('Network.enable');
|
|
186
|
+
const urls = (cookieUrls && cookieUrls.length) ? cookieUrls : [`https://${serverHost}/`];
|
|
187
|
+
const result = await cdp.send('Network.getCookies', { urls });
|
|
188
|
+
const cookies = (result.cookies || [])
|
|
189
|
+
.filter(c => c.domain && (c.domain === serverHost || c.domain.endsWith(`.${serverHost}`)))
|
|
190
|
+
.map(c => `${c.name}=${c.value}`);
|
|
191
|
+
return cookies.join('; ');
|
|
192
|
+
} finally {
|
|
193
|
+
cdp.close();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function fetchText(url, cookie, options = {}) {
|
|
198
|
+
const method = options.method || 'GET';
|
|
199
|
+
const headers = {
|
|
200
|
+
Cookie: cookie,
|
|
201
|
+
Accept: options.accept || '*/*',
|
|
202
|
+
'User-Agent': ua,
|
|
203
|
+
};
|
|
204
|
+
if (options.body !== undefined && options.body !== null) headers['Content-Type'] = options.contentType || 'application/json';
|
|
205
|
+
const res = await fetch(url, { method, redirect: 'follow', headers, body: options.body ?? null });
|
|
206
|
+
return { status: res.status, contentType: res.headers.get('content-type') || '', text: await res.text() };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function fetchJson(url, cookie, options = {}) {
|
|
210
|
+
const result = await fetchText(url, cookie, { ...options, accept: options.accept || 'application/json' });
|
|
211
|
+
let json = null;
|
|
212
|
+
try { json = JSON.parse(result.text); } catch {}
|
|
213
|
+
return { ...result, json };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function getCookieWithWait(openUrl, { tabPathPrefix } = {}) {
|
|
217
|
+
await ensureBrowser(openUrl, { tabPathPrefix });
|
|
218
|
+
console.log(`If prompted in Chrome, complete SSO for: ${openUrl}`);
|
|
219
|
+
const deadline = Date.now() + waitSec * 1000;
|
|
220
|
+
let last = '';
|
|
221
|
+
while (Date.now() < deadline) {
|
|
222
|
+
try {
|
|
223
|
+
const cookie = await getCookieHeader();
|
|
224
|
+
const result = await verifySession(cookie);
|
|
225
|
+
if (result && result.ok) {
|
|
226
|
+
if (process.stdout.isTTY) process.stdout.write('\n');
|
|
227
|
+
console.log(`Authenticated session verified${result.url ? ` via ${result.url}` : ''}`);
|
|
228
|
+
return cookie;
|
|
229
|
+
}
|
|
230
|
+
last = (result && result.message) || 'session not yet verified';
|
|
231
|
+
} catch (e) { last = e.message; }
|
|
232
|
+
if (process.stdout.isTTY) {
|
|
233
|
+
process.stdout.write(`\r${new Date().toLocaleTimeString()} ${last.padEnd(120).slice(0, 120)}`);
|
|
234
|
+
}
|
|
235
|
+
await sleep(3000);
|
|
236
|
+
}
|
|
237
|
+
if (process.stdout.isTTY) process.stdout.write('\n');
|
|
238
|
+
throw new Error(`Could not verify authenticated session. Last result: ${last}`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
devtoolsReady,
|
|
243
|
+
waitDevtools,
|
|
244
|
+
openDevtoolsTab,
|
|
245
|
+
hasDevtoolsTabForHost,
|
|
246
|
+
launchChrome,
|
|
247
|
+
ensureBrowser,
|
|
248
|
+
getPageWsUrl,
|
|
249
|
+
getCookieHeader,
|
|
250
|
+
getCookieWithWait,
|
|
251
|
+
fetchText,
|
|
252
|
+
fetchJson,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
module.exports = {
|
|
257
|
+
createBrowserSession,
|
|
258
|
+
findBrowserExecutable,
|
|
259
|
+
resolveBrowserCandidate,
|
|
260
|
+
connectCdp,
|
|
261
|
+
};
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
-
const fs = require('fs');
|
|
5
4
|
const fsp = require('fs/promises');
|
|
6
5
|
const os = require('os');
|
|
7
6
|
const path = require('path');
|
|
8
|
-
const {
|
|
7
|
+
const { createBrowserSession } = require('./atlassian-browser');
|
|
9
8
|
const {
|
|
10
9
|
parseProjectInput,
|
|
11
10
|
repositoriesApiUrl,
|
|
@@ -71,182 +70,23 @@ project.browseUrl = `https://bitbucket.org/${project.workspace}/workspace/projec
|
|
|
71
70
|
opts.rawDir = path.resolve(opts.rawDir);
|
|
72
71
|
opts.pagelen = Math.min(100, Math.max(1, opts.pagelen || 100));
|
|
73
72
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
async function waitDevtools() {
|
|
87
|
-
for (let i = 0; i < 80; i++) {
|
|
88
|
-
if (await devtoolsReady()) return;
|
|
89
|
-
await sleep(250);
|
|
90
|
-
}
|
|
91
|
-
throw new Error('Chrome DevTools endpoint did not start');
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
async function openDevtoolsTab(url) {
|
|
95
|
-
const endpointUrl = `http://127.0.0.1:${opts.port}/json/new?${encodeURIComponent(url)}`;
|
|
96
|
-
for (const init of [{ method: 'PUT' }, {}]) {
|
|
97
|
-
try {
|
|
98
|
-
const res = await fetch(endpointUrl, init);
|
|
99
|
-
if (res.ok) { await sleep(1000); return true; }
|
|
100
|
-
} catch {}
|
|
101
|
-
}
|
|
102
|
-
return false;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
async function hasBitbucketTab(url) {
|
|
106
|
-
const host = new URL(url).host;
|
|
107
|
-
const list = await endpoint('/json/list');
|
|
108
|
-
return list.some(t => t.type === 'page' && t.url && (() => {
|
|
109
|
-
try { return new URL(t.url).host === host; } catch { return false; }
|
|
110
|
-
})());
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function isExecutable(file) {
|
|
114
|
-
try { fs.accessSync(file, fs.constants.X_OK); return true; } catch { return false; }
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function resolveBrowserCandidate(candidate) {
|
|
118
|
-
if (!candidate) return null;
|
|
119
|
-
if (candidate.includes(path.sep)) return isExecutable(candidate) ? candidate : null;
|
|
120
|
-
for (const dir of String(process.env.PATH || '').split(path.delimiter)) {
|
|
121
|
-
if (!dir) continue;
|
|
122
|
-
const full = path.join(dir, candidate);
|
|
123
|
-
if (isExecutable(full)) return full;
|
|
124
|
-
}
|
|
125
|
-
return null;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function findBrowserExecutable() {
|
|
129
|
-
const candidates = [
|
|
130
|
-
process.env.CHROME,
|
|
131
|
-
process.env.CHROMIUM,
|
|
132
|
-
'google-chrome',
|
|
133
|
-
'google-chrome-stable',
|
|
134
|
-
'chromium',
|
|
135
|
-
'chromium-browser',
|
|
136
|
-
'brave-browser',
|
|
137
|
-
'brave',
|
|
138
|
-
'microsoft-edge',
|
|
139
|
-
'microsoft-edge-stable',
|
|
140
|
-
'vivaldi',
|
|
141
|
-
'vivaldi-stable',
|
|
142
|
-
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
143
|
-
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
144
|
-
'/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
|
|
145
|
-
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
146
|
-
'/Applications/Vivaldi.app/Contents/MacOS/Vivaldi',
|
|
147
|
-
];
|
|
148
|
-
for (const candidate of candidates) {
|
|
149
|
-
const resolved = resolveBrowserCandidate(candidate);
|
|
150
|
-
if (resolved) return resolved;
|
|
151
|
-
}
|
|
152
|
-
throw new Error('Could not find a Chromium-compatible browser. Install Chrome/Chromium/Brave/Edge/Vivaldi or set CHROME=/path/to/browser.');
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function launchChrome(url) {
|
|
156
|
-
const browser = findBrowserExecutable();
|
|
157
|
-
const args = [
|
|
158
|
-
`--remote-debugging-port=${opts.port}`,
|
|
159
|
-
'--remote-debugging-address=127.0.0.1',
|
|
160
|
-
'--remote-allow-origins=*',
|
|
161
|
-
`--user-data-dir=${opts.profileDir}`,
|
|
162
|
-
'--no-first-run',
|
|
163
|
-
'--no-default-browser-check',
|
|
164
|
-
url,
|
|
165
|
-
];
|
|
166
|
-
console.log(`Launching browser: ${browser}`);
|
|
167
|
-
const child = spawn(browser, args, { detached: true, stdio: 'ignore' });
|
|
168
|
-
child.on('error', err => console.error(`Failed to launch browser ${browser}: ${err.message}`));
|
|
169
|
-
child.unref();
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
async function ensureBrowser(openUrl) {
|
|
173
|
-
if (!(await devtoolsReady())) {
|
|
174
|
-
console.log(`Opening Chromium-compatible browser with reusable profile: ${opts.profileDir}`);
|
|
175
|
-
launchChrome(openUrl);
|
|
176
|
-
} else {
|
|
177
|
-
console.log(`Reusing Chrome DevTools on port ${opts.port}`);
|
|
178
|
-
if (await hasBitbucketTab(openUrl)) console.log(`Found existing Bitbucket tab for ${new URL(openUrl).host}; not opening another tab.`);
|
|
179
|
-
else if (await openDevtoolsTab(openUrl)) console.log(`Opened target URL in reused browser: ${openUrl}`);
|
|
180
|
-
else console.warn('Could not open target URL through DevTools; continuing with existing tabs.');
|
|
181
|
-
}
|
|
182
|
-
await waitDevtools();
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
async function getPageWsUrl() {
|
|
186
|
-
const list = await endpoint('/json/list');
|
|
187
|
-
const pages = list.filter(t => t.type === 'page' && t.webSocketDebuggerUrl);
|
|
188
|
-
const preferred = pages.find(t => (t.url || '').includes('bitbucket.org')) || pages[0];
|
|
189
|
-
return preferred && preferred.webSocketDebuggerUrl;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function connectCdp(wsUrl) {
|
|
193
|
-
return new Promise((resolve, reject) => {
|
|
194
|
-
const ws = new WebSocket(wsUrl);
|
|
195
|
-
let id = 0;
|
|
196
|
-
const pending = new Map();
|
|
197
|
-
const failTimer = setTimeout(() => reject(new Error('CDP websocket timeout')), 10000);
|
|
198
|
-
ws.addEventListener('open', () => {
|
|
199
|
-
clearTimeout(failTimer);
|
|
200
|
-
resolve({
|
|
201
|
-
send(method, params = {}) {
|
|
202
|
-
return new Promise((res, rej) => {
|
|
203
|
-
const msgId = ++id;
|
|
204
|
-
pending.set(msgId, { res, rej });
|
|
205
|
-
ws.send(JSON.stringify({ id: msgId, method, params }));
|
|
206
|
-
});
|
|
207
|
-
},
|
|
208
|
-
close() { try { ws.close(); } catch {} },
|
|
209
|
-
});
|
|
210
|
-
});
|
|
211
|
-
ws.addEventListener('message', ev => {
|
|
212
|
-
let data = ev.data;
|
|
213
|
-
if (typeof data !== 'string') data = Buffer.from(data).toString('utf8');
|
|
214
|
-
const msg = JSON.parse(data);
|
|
215
|
-
if (!msg.id || !pending.has(msg.id)) return;
|
|
216
|
-
const { res, rej } = pending.get(msg.id);
|
|
217
|
-
pending.delete(msg.id);
|
|
218
|
-
if (msg.error) rej(new Error(`${msg.error.message || 'CDP error'} ${JSON.stringify(msg.error)}`));
|
|
219
|
-
else res(msg.result);
|
|
220
|
-
});
|
|
221
|
-
ws.addEventListener('error', err => reject(err));
|
|
73
|
+
let session = null;
|
|
74
|
+
function getSession() {
|
|
75
|
+
if (session) return session;
|
|
76
|
+
session = createBrowserSession({
|
|
77
|
+
port: opts.port,
|
|
78
|
+
profileDir: opts.profileDir,
|
|
79
|
+
waitSec: opts.waitSec,
|
|
80
|
+
serverHost: 'bitbucket.org',
|
|
81
|
+
cookieUrls: ['https://bitbucket.org/'],
|
|
82
|
+
userAgent: 'bitbucket-browser-fetch/1.0',
|
|
83
|
+
verifySession: cookie => verifyBitbucketSession(cookie),
|
|
222
84
|
});
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
async function getCookieHeader() {
|
|
226
|
-
const wsUrl = await getPageWsUrl();
|
|
227
|
-
if (!wsUrl) return '';
|
|
228
|
-
const cdp = await connectCdp(wsUrl);
|
|
229
|
-
try {
|
|
230
|
-
await cdp.send('Network.enable');
|
|
231
|
-
const result = await cdp.send('Network.getCookies', { urls: ['https://bitbucket.org/'] });
|
|
232
|
-
return (result.cookies || [])
|
|
233
|
-
.filter(c => c.domain && (c.domain === 'bitbucket.org' || c.domain.endsWith('.bitbucket.org')))
|
|
234
|
-
.map(c => `${c.name}=${c.value}`)
|
|
235
|
-
.join('; ');
|
|
236
|
-
} finally {
|
|
237
|
-
cdp.close();
|
|
238
|
-
}
|
|
85
|
+
return session;
|
|
239
86
|
}
|
|
240
87
|
|
|
241
88
|
async function fetchJson(url, cookie) {
|
|
242
|
-
|
|
243
|
-
headers: { Cookie: cookie, Accept: 'application/json', 'User-Agent': 'bitbucket-browser-fetch/1.0' },
|
|
244
|
-
redirect: 'follow',
|
|
245
|
-
});
|
|
246
|
-
const text = await res.text();
|
|
247
|
-
let json = null;
|
|
248
|
-
try { json = JSON.parse(text); } catch {}
|
|
249
|
-
return { status: res.status, contentType: res.headers.get('content-type') || '', text, json };
|
|
89
|
+
return getSession().fetchJson(url, cookie, { accept: 'application/json' });
|
|
250
90
|
}
|
|
251
91
|
|
|
252
92
|
async function verifyBitbucketSession(cookie) {
|
|
@@ -260,29 +100,8 @@ async function verifyBitbucketSession(cookie) {
|
|
|
260
100
|
return { ok: false, message: `session probe HTTP ${result.status} from ${url}` };
|
|
261
101
|
}
|
|
262
102
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
console.log(`If prompted in Chrome, complete Bitbucket/Atlassian login for: ${project.browseUrl}`);
|
|
266
|
-
const deadline = Date.now() + opts.waitSec * 1000;
|
|
267
|
-
let last = '';
|
|
268
|
-
let printedWait = false;
|
|
269
|
-
while (Date.now() < deadline) {
|
|
270
|
-
try {
|
|
271
|
-
const cookie = await getCookieHeader();
|
|
272
|
-
const session = await verifyBitbucketSession(cookie);
|
|
273
|
-
if (session.ok) {
|
|
274
|
-
if (process.stdout.isTTY) process.stdout.write('\n');
|
|
275
|
-
console.log(`Authenticated Bitbucket session verified via ${session.url}`);
|
|
276
|
-
return cookie;
|
|
277
|
-
}
|
|
278
|
-
last = session.message;
|
|
279
|
-
} catch (e) { last = e.message; }
|
|
280
|
-
if (process.stdout.isTTY) process.stdout.write(`\r${new Date().toLocaleTimeString()} ${last.padEnd(120).slice(0, 120)}`);
|
|
281
|
-
else if (!printedWait) { console.log(`Waiting up to ${opts.waitSec}s for Bitbucket session...`); printedWait = true; }
|
|
282
|
-
await sleep(3000);
|
|
283
|
-
}
|
|
284
|
-
if (process.stdout.isTTY) process.stdout.write('\n');
|
|
285
|
-
throw new Error(`Could not verify authenticated Bitbucket session. Last result: ${last}`);
|
|
103
|
+
function getCookieWithWait() {
|
|
104
|
+
return getSession().getCookieWithWait(project.browseUrl);
|
|
286
105
|
}
|
|
287
106
|
|
|
288
107
|
async function fetchRepositories(cookie) {
|