@aholbreich/agent-skills 0.8.0 → 0.9.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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ Added:
6
+
7
+ - 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.
8
+
3
9
  ## 0.8.0 - 2026-05-07
4
10
 
5
11
  Added:
package/README.md CHANGED
@@ -2,7 +2,7 @@
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/update tools that work well when Jira/Confluence API-token authentication is unavailable because an organization uses Microsoft/SSO.
5
+ This repository is a pure skills package. It currently contains browser-authenticated Atlassian fetch/update tools 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
 
@@ -11,6 +11,7 @@ This repository is a pure skills package. It currently contains browser-authenti
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
12
  | [`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
13
  | [`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
+ | [`bitbucket-browser-fetch`](skills/bitbucket-browser-fetch/) | Fetch Bitbucket Cloud project repository inventories and clone URL lists through an authenticated browser session. |
14
15
 
15
16
  ## Compatibility
16
17
 
@@ -177,6 +178,7 @@ agent-skills
177
178
  jira-browser-fetch
178
179
  confluence-browser-fetch
179
180
  confluence-update
181
+ bitbucket-browser-fetch
180
182
  ```
181
183
 
182
184
  ## Reuse one Atlassian browser login
@@ -188,7 +190,17 @@ export ATLASSIAN_CHROME_PROFILE="$HOME/.local/share/atlassian-browser-fetch-chro
188
190
  export ATLASSIAN_CHROME_DEBUG_PORT=9223
189
191
  ```
190
192
 
191
- Skill-specific variables such as `JIRA_CHROME_PROFILE` or `CONFLUENCE_CHROME_PROFILE` still override the shared profile when needed.
193
+ Skill-specific variables such as `JIRA_CHROME_PROFILE`, `CONFLUENCE_CHROME_PROFILE`, or `BITBUCKET_CHROME_PROFILE` still override the shared profile when needed.
194
+
195
+ ## Bitbucket examples
196
+
197
+ Fetch all repositories in a Bitbucket project and write SSH clone URL lists:
198
+
199
+ ```bash
200
+ bitbucket-browser-fetch \
201
+ 'https://bitbucket.org/myneva/workspace/projects/SWI' \
202
+ --raw-dir ./raw
203
+ ```
192
204
 
193
205
  ## Confluence update examples
194
206
 
package/SECURITY.md CHANGED
@@ -2,16 +2,16 @@
2
2
 
3
3
  ## Read this before installing or running
4
4
 
5
- These skills are local automation tools. They can fetch potentially sensitive Jira and Confluence data into your filesystem, and `confluence-update` can write to Confluence when explicitly run with `--apply`.
5
+ These skills are local automation tools. They can fetch potentially sensitive Jira, Confluence, and Bitbucket data into your filesystem, and `confluence-update` can write to Confluence when explicitly run with `--apply`.
6
6
 
7
7
  ## Browser authentication model
8
8
 
9
- The Jira and Confluence browser tools:
9
+ The Jira, Confluence, and Bitbucket browser tools:
10
10
 
11
11
  1. launch or reuse a Chromium-compatible browser with a dedicated local profile,
12
12
  2. let you complete normal Atlassian SSO in the browser,
13
13
  3. read Atlassian cookies through the local Chrome DevTools protocol,
14
- 4. verify those cookies represent an authenticated Jira/Confluence REST session,
14
+ 4. verify those cookies represent an authenticated Jira/Confluence/Bitbucket session,
15
15
  5. call Atlassian REST endpoints with those cookies.
16
16
 
17
17
  They do **not** require you to paste API tokens or cookies into chat.
@@ -20,7 +20,7 @@ They do **not** require you to paste API tokens or cookies into chat.
20
20
 
21
21
  - Do not paste Atlassian cookies, API tokens, passwords, or session headers into prompts, issues, logs, or commits.
22
22
  - Treat everything under `raw/` as confidential unless you know it is public.
23
- - Do not commit fetched Jira/Confluence exports, update audit files, or attachments to a public repository.
23
+ - Do not commit fetched Jira/Confluence/Bitbucket exports, update audit files, clone URL lists, or attachments to a public repository.
24
24
  - Review generated `attachments.json` manifests before sharing; they may contain private URLs and filenames.
25
25
  - Chrome remote debugging is configured for `127.0.0.1`; do not expose it to a network interface.
26
26
  - Use dedicated browser profiles for fetch automation. If reusing SSO between Jira and Confluence, share only a dedicated automation profile via `ATLASSIAN_CHROME_PROFILE`, not your everyday browser profile.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aholbreich/agent-skills",
3
- "version": "0.8.0",
3
+ "version": "0.9.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",
@@ -38,10 +38,11 @@
38
38
  "agent-skills": "bin/agent-skills.js",
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
- "confluence-update": "skills/confluence-update/scripts/confluence-update.js"
41
+ "confluence-update": "skills/confluence-update/scripts/confluence-update.js",
42
+ "bitbucket-browser-fetch": "skills/bitbucket-browser-fetch/scripts/bitbucket-browser-fetch.js"
42
43
  },
43
44
  "scripts": {
44
- "check": "node --check bin/agent-skills.js && node --check skills/jira-browser-fetch/scripts/jira-browser-fetch.js && node --check skills/jira-browser-fetch/scripts/lib.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-update/scripts/confluence-update.js && node --check skills/confluence-update/scripts/lib.js",
45
+ "check": "node --check bin/agent-skills.js && node --check skills/jira-browser-fetch/scripts/jira-browser-fetch.js && node --check skills/jira-browser-fetch/scripts/lib.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-update/scripts/confluence-update.js && node --check skills/confluence-update/scripts/lib.js && node --check skills/bitbucket-browser-fetch/scripts/bitbucket-browser-fetch.js && node --check skills/bitbucket-browser-fetch/scripts/lib.js",
45
46
  "test": "node --test",
46
47
  "ci": "npm run check && npm test && npm pack --dry-run",
47
48
  "pack:dry": "npm pack --dry-run",
@@ -0,0 +1,66 @@
1
+ ---
2
+ name: bitbucket-browser-fetch
3
+ description: Fetch Bitbucket Cloud project repository inventory through an authenticated browser session when API tokens/app passwords are unavailable, especially with Atlassian SSO. Use to list repositories in a Bitbucket workspace project and produce JSON, Markdown, SSH clone URL lists, HTTPS clone URL lists, and a safe clone helper script.
4
+ license: MIT
5
+ compatibility: Agent Skills standard. Tested with Pi; installable into Claude Code, Codex, OpenClaw/generic .agents skills directories. Requires Node.js 22+ with built-in fetch/WebSocket and a Chromium-compatible browser with remote debugging (Chrome, Chromium, Brave, Edge, or Vivaldi). No npm dependencies.
6
+ ---
7
+
8
+ # Bitbucket Browser Fetch
9
+
10
+ Use this skill when a user wants all repositories in a Bitbucket Cloud project inventoried through an authenticated browser session. This is useful when Bitbucket app passwords/API tokens are unavailable or inconvenient because the organization uses Atlassian SSO.
11
+
12
+ The script opens/reuses Chrome with a dedicated profile, lets the user complete Bitbucket login once, extracts Bitbucket cookies via Chrome DevTools, verifies project access, and fetches the repository list using Bitbucket's browser/internal API.
13
+
14
+ ## Safety
15
+
16
+ - Never ask the user to paste Bitbucket cookies, app passwords, or API tokens into chat.
17
+ - The skill is read-only and does not clone repositories itself.
18
+ - It writes clone URL lists and a helper script; review before executing any clone script.
19
+ - Treat repository names/URLs as potentially confidential.
20
+
21
+ ## Script
22
+
23
+ ```bash
24
+ scripts/bitbucket-browser-fetch.js <PROJECT_URL> [options]
25
+ ```
26
+
27
+ Important options:
28
+
29
+ ```bash
30
+ --workspace NAME override workspace parsed from URL
31
+ --project KEY override project key parsed from URL
32
+ --raw-dir DIR output raw directory
33
+ --pagelen N internal API page size, default 100
34
+ --wait SEC SSO/session wait timeout
35
+ --port PORT Chrome DevTools port
36
+ --profile-dir DIR Chrome profile dir
37
+ ```
38
+
39
+ ## Example
40
+
41
+ ```bash
42
+ scripts/bitbucket-browser-fetch.js \
43
+ 'https://bitbucket.org/myneva/workspace/projects/SWI' \
44
+ --raw-dir ./raw
45
+ ```
46
+
47
+ ## Output
48
+
49
+ ```text
50
+ raw/bitbucket/<workspace>/projects/<project-key>/
51
+ ├── repositories.json
52
+ ├── repositories.md
53
+ ├── clone-ssh.txt
54
+ ├── clone-https.txt
55
+ ├── clone-ssh.sh
56
+ ├── bitbucket-browser-fetch-run.json
57
+ └── pages/
58
+ └── repositories-page-1.json
59
+ ```
60
+
61
+ Agents should normally use `repositories.json` for metadata and `clone-ssh.txt` for selective checkout with normal Git SSH credentials.
62
+
63
+ ## References
64
+
65
+ - [Usage reference](references/usage.md)
66
+ - [Distribution guide](references/distribution.md)
@@ -0,0 +1,25 @@
1
+ # Bitbucket Browser Fetch Distribution
2
+
3
+ This skill is distributed as part of `@aholbreich/agent-skills`.
4
+
5
+ Directory layout:
6
+
7
+ ```text
8
+ bitbucket-browser-fetch/
9
+ ├── SKILL.md
10
+ ├── references/
11
+ │ ├── distribution.md
12
+ │ └── usage.md
13
+ └── scripts/
14
+ ├── bitbucket-browser-fetch.js
15
+ └── lib.js
16
+ ```
17
+
18
+ Use directly by path or install a convenience symlink:
19
+
20
+ ```bash
21
+ mkdir -p ~/.local/bin
22
+ ln -sf ~/.pi/agent/skills/bitbucket-browser-fetch/scripts/bitbucket-browser-fetch.js ~/.local/bin/bitbucket-browser-fetch
23
+ ```
24
+
25
+ The package exposes a `bitbucket-browser-fetch` npm bin when installed globally.
@@ -0,0 +1,84 @@
1
+ # Bitbucket Browser Fetch Usage
2
+
3
+ ## Why Browser Fetch?
4
+
5
+ Bitbucket Cloud organizations often rely on Atlassian SSO. Browser fetch avoids pasted secrets by:
6
+
7
+ 1. Launching/reusing a dedicated Chromium-compatible browser profile.
8
+ 2. Letting the user complete normal Bitbucket/Atlassian login in the browser.
9
+ 3. Reading Bitbucket cookies through local Chrome DevTools.
10
+ 4. Verifying access to the requested Bitbucket project.
11
+ 5. Calling Bitbucket's browser/internal API to list project repositories.
12
+
13
+ The official `api.bitbucket.org/2.0` API does not reliably accept browser cookies, so this skill uses the same internal API the Bitbucket UI uses for project repository lists.
14
+
15
+ ## Common Command
16
+
17
+ ```bash
18
+ scripts/bitbucket-browser-fetch.js \
19
+ 'https://bitbucket.org/myneva/workspace/projects/SWI' \
20
+ --raw-dir ./raw
21
+ ```
22
+
23
+ ## Output Files
24
+
25
+ For `https://bitbucket.org/myneva/workspace/projects/SWI`:
26
+
27
+ ```text
28
+ raw/bitbucket/myneva/projects/SWI/
29
+ ├── repositories.json # normalized machine-readable inventory
30
+ ├── repositories.md # human/LLM-friendly table
31
+ ├── clone-ssh.txt # one SSH git clone URL per line
32
+ ├── clone-https.txt # one HTTPS git clone URL per line
33
+ ├── clone-ssh.sh # safe helper script; not executed automatically
34
+ ├── bitbucket-browser-fetch-run.json
35
+ └── pages/
36
+ ├── repositories-page-1.json # raw Bitbucket internal API responses
37
+ └── repositories-page-2.json
38
+ ```
39
+
40
+ ## Agent Checkout Workflow
41
+
42
+ The skill does not clone automatically. After reviewing the output, agents can selectively clone with normal Git SSH credentials:
43
+
44
+ ```bash
45
+ mkdir -p repos
46
+ while read -r url; do
47
+ name="$(basename "$url" .git)"
48
+ [ -d "repos/$name/.git" ] && echo "SKIP $name" && continue
49
+ git clone "$url" "repos/$name"
50
+ done < raw/bitbucket/myneva/projects/SWI/clone-ssh.txt
51
+ ```
52
+
53
+ Or run the generated helper script after review:
54
+
55
+ ```bash
56
+ raw/bitbucket/myneva/projects/SWI/clone-ssh.sh repos
57
+ ```
58
+
59
+ ## Environment Variables
60
+
61
+ | Variable | Meaning |
62
+ |---|---|
63
+ | `BITBUCKET_RAW_DIR` | Default output raw directory |
64
+ | `BITBUCKET_CHROME_DEBUG_PORT` | Chrome DevTools port; overrides `ATLASSIAN_CHROME_DEBUG_PORT` |
65
+ | `ATLASSIAN_CHROME_DEBUG_PORT` | Shared DevTools port for Atlassian browser tools |
66
+ | `BITBUCKET_CHROME_PROFILE` | Dedicated Chrome profile dir; overrides `ATLASSIAN_CHROME_PROFILE` |
67
+ | `ATLASSIAN_CHROME_PROFILE` | Shared browser profile dir for Atlassian tools |
68
+ | `BITBUCKET_FETCH_WAIT_SEC` | Wait timeout, default `900` |
69
+ | `BITBUCKET_PAGELEN` | Internal API page size, default `100` |
70
+ | `CHROME` / `CHROMIUM` | Browser executable path override |
71
+
72
+ ## Troubleshooting
73
+
74
+ ### Project not found / no access
75
+
76
+ Complete Bitbucket/Atlassian login in the opened browser and confirm you can view the project URL manually.
77
+
78
+ ### Official API returns empty but UI shows repositories
79
+
80
+ Expected in SSO/browser-cookie mode. This skill intentionally uses Bitbucket's browser/internal API.
81
+
82
+ ### Git clone fails
83
+
84
+ Browser authentication and Git authentication are separate. Configure SSH keys or Git credentials for Bitbucket before cloning.
@@ -0,0 +1,344 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const fsp = require('fs/promises');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const { spawn } = require('child_process');
9
+ const {
10
+ parseProjectInput,
11
+ repositoriesApiUrl,
12
+ normalizeRepo,
13
+ safeName,
14
+ repositoriesMarkdown,
15
+ cloneScript,
16
+ } = require('./lib');
17
+
18
+ function usage() {
19
+ console.log(`Usage: bitbucket-browser-fetch <PROJECT_URL> [options]
20
+
21
+ Fetch Bitbucket Cloud project repository inventory through an authenticated browser session.
22
+
23
+ Options:
24
+ --workspace NAME Override workspace parsed from URL
25
+ --project KEY Override project key parsed from URL
26
+ --raw-dir DIR Output raw directory (default: BITBUCKET_RAW_DIR or ./raw)
27
+ --pagelen N Internal API page size (default: 100)
28
+ --wait SEC Wait time for login/session (default: 900)
29
+ --port PORT Chrome DevTools port (default: BITBUCKET_CHROME_DEBUG_PORT, ATLASSIAN_CHROME_DEBUG_PORT, or 9224)
30
+ --profile-dir DIR Chrome profile dir (default: BITBUCKET_CHROME_PROFILE, ATLASSIAN_CHROME_PROFILE, or ~/.local/share/bitbucket-browser-fetch-chrome)
31
+ --help Show this help
32
+
33
+ Examples:
34
+ bitbucket-browser-fetch 'https://bitbucket.org/myneva/workspace/projects/SWI' --raw-dir raw
35
+ `);
36
+ }
37
+
38
+ const opts = {
39
+ projectUrl: '',
40
+ workspace: '',
41
+ projectKey: '',
42
+ rawDir: process.env.BITBUCKET_RAW_DIR || path.resolve(process.cwd(), 'raw'),
43
+ port: Number(process.env.BITBUCKET_CHROME_DEBUG_PORT || process.env.ATLASSIAN_CHROME_DEBUG_PORT || 9224),
44
+ waitSec: Number(process.env.BITBUCKET_FETCH_WAIT_SEC || 900),
45
+ profileDir: process.env.BITBUCKET_CHROME_PROFILE || process.env.ATLASSIAN_CHROME_PROFILE || path.join(os.homedir(), '.local/share/bitbucket-browser-fetch-chrome'),
46
+ pagelen: Number(process.env.BITBUCKET_PAGELEN || 100),
47
+ };
48
+
49
+ const args = process.argv.slice(2);
50
+ for (let i = 0; i < args.length; i++) {
51
+ const a = args[i];
52
+ if (a === '-h' || a === '--help') { usage(); process.exit(0); }
53
+ else if (a === '--workspace') opts.workspace = args[++i];
54
+ else if (a === '--project') opts.projectKey = args[++i].toUpperCase();
55
+ else if (a === '--raw-dir') opts.rawDir = args[++i];
56
+ else if (a === '--pagelen') opts.pagelen = Number(args[++i]);
57
+ else if (a === '--wait') opts.waitSec = Number(args[++i]);
58
+ else if (a === '--port') opts.port = Number(args[++i]);
59
+ else if (a === '--profile-dir') opts.profileDir = args[++i];
60
+ else if (!a.startsWith('-') && !opts.projectUrl) opts.projectUrl = a;
61
+ else { console.error(`Unknown argument: ${a}`); process.exit(2); }
62
+ }
63
+
64
+ if (!opts.projectUrl && (!opts.workspace || !opts.projectKey)) { usage(); process.exit(2); }
65
+ let project = null;
66
+ if (opts.projectUrl) project = parseProjectInput(opts.projectUrl);
67
+ else project = { source: '', workspace: opts.workspace, projectKey: opts.projectKey, browseUrl: `https://bitbucket.org/${opts.workspace}/workspace/projects/${opts.projectKey}` };
68
+ if (opts.workspace) project.workspace = opts.workspace;
69
+ if (opts.projectKey) project.projectKey = opts.projectKey.toUpperCase();
70
+ project.browseUrl = `https://bitbucket.org/${project.workspace}/workspace/projects/${project.projectKey}`;
71
+ opts.rawDir = path.resolve(opts.rawDir);
72
+ opts.pagelen = Math.min(100, Math.max(1, opts.pagelen || 100));
73
+
74
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
75
+
76
+ async function endpoint(pathname) {
77
+ const res = await fetch(`http://127.0.0.1:${opts.port}${pathname}`);
78
+ if (!res.ok) throw new Error(`DevTools HTTP ${res.status} for ${pathname}`);
79
+ return res.json();
80
+ }
81
+
82
+ async function devtoolsReady() {
83
+ try { await endpoint('/json/version'); return true; } catch { return false; }
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));
222
+ });
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
+ }
239
+ }
240
+
241
+ async function fetchJson(url, cookie) {
242
+ const res = await fetch(url, {
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 };
250
+ }
251
+
252
+ async function verifyBitbucketSession(cookie) {
253
+ if (!cookie) return { ok: false, message: 'no Bitbucket cookies yet' };
254
+ const url = `https://bitbucket.org/!api/internal/menu/project/${encodeURIComponent(project.workspace)}/${encodeURIComponent(project.projectKey)}`;
255
+ const result = await fetchJson(url, cookie);
256
+ if (result.status === 200 && result.json) return { ok: true, url };
257
+ if (result.status === 401 || result.status === 403 || result.status === 404) {
258
+ return { ok: false, message: `not authenticated or no project access (${result.status} from ${url})` };
259
+ }
260
+ return { ok: false, message: `session probe HTTP ${result.status} from ${url}` };
261
+ }
262
+
263
+ async function getCookieWithWait() {
264
+ await ensureBrowser(project.browseUrl);
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}`);
286
+ }
287
+
288
+ async function fetchRepositories(cookie) {
289
+ const pages = [];
290
+ const repos = [];
291
+ let page = 1;
292
+ let nextUrl = repositoriesApiUrl(project.workspace, project.projectKey, page, opts.pagelen);
293
+ while (nextUrl) {
294
+ const result = await fetchJson(nextUrl, cookie);
295
+ if (result.status !== 200 || !result.json || !Array.isArray(result.json.values)) {
296
+ throw new Error(`Repository list failed HTTP ${result.status}: ${(result.text || '').slice(0, 500).replace(/\s+/g, ' ')}`);
297
+ }
298
+ pages.push(result.json);
299
+ repos.push(...result.json.values.map(normalizeRepo));
300
+ console.log(`Fetched Bitbucket repositories page ${result.json.page || page}: ${result.json.values.length} repo(s)`);
301
+ nextUrl = result.json.next || '';
302
+ page += 1;
303
+ }
304
+ return { pages, repos };
305
+ }
306
+
307
+ async function main() {
308
+ await fsp.mkdir(opts.rawDir, { recursive: true });
309
+ const cookie = await getCookieWithWait();
310
+ const { pages, repos } = await fetchRepositories(cookie);
311
+ const outDir = path.join(opts.rawDir, 'bitbucket', safeName(project.workspace), 'projects', safeName(project.projectKey));
312
+ await fsp.mkdir(path.join(outDir, 'pages'), { recursive: true });
313
+
314
+ const manifest = {
315
+ fetchedAt: new Date().toISOString(),
316
+ source: project.source || project.browseUrl,
317
+ browseUrl: project.browseUrl,
318
+ workspace: project.workspace,
319
+ projectKey: project.projectKey,
320
+ repositoryCount: repos.length,
321
+ repositories: repos,
322
+ };
323
+
324
+ await fsp.writeFile(path.join(outDir, 'repositories.json'), JSON.stringify(manifest, null, 2));
325
+ await fsp.writeFile(path.join(outDir, 'repositories.md'), repositoriesMarkdown(manifest));
326
+ await fsp.writeFile(path.join(outDir, 'clone-ssh.txt'), repos.map(r => r.clone && r.clone.ssh).filter(Boolean).join('\n') + '\n');
327
+ await fsp.writeFile(path.join(outDir, 'clone-https.txt'), repos.map(r => r.clone && r.clone.https).filter(Boolean).join('\n') + '\n');
328
+ await fsp.writeFile(path.join(outDir, 'clone-ssh.sh'), cloneScript(), { mode: 0o755 });
329
+ for (let i = 0; i < pages.length; i++) {
330
+ await fsp.writeFile(path.join(outDir, 'pages', `repositories-page-${i + 1}.json`), JSON.stringify(pages[i], null, 2));
331
+ }
332
+ const runMeta = { fetchedAt: manifest.fetchedAt, workspace: project.workspace, projectKey: project.projectKey, rawDir: outDir, repositoryCount: repos.length };
333
+ await fsp.writeFile(path.join(outDir, 'bitbucket-browser-fetch-run.json'), JSON.stringify(runMeta, null, 2));
334
+
335
+ console.log(`\nFetched ${repos.length} Bitbucket repos for ${project.workspace}/${project.projectKey}`);
336
+ console.log(`Saved ${path.join(outDir, 'repositories.json')}`);
337
+ console.log(`SSH clone list: ${path.join(outDir, 'clone-ssh.txt')}`);
338
+ for (const repo of repos) console.log(`- ${repo.fullName || repo.name}`);
339
+ }
340
+
341
+ main().catch(err => {
342
+ console.error(`\nERROR: ${err.stack || err.message}`);
343
+ process.exit(1);
344
+ });
@@ -0,0 +1,113 @@
1
+ 'use strict';
2
+
3
+ function parseProjectInput(input) {
4
+ const source = String(input || '').trim();
5
+ if (!source) throw new Error('Missing Bitbucket project URL');
6
+ try {
7
+ const url = new URL(source);
8
+ if (url.hostname !== 'bitbucket.org') throw new Error('Expected bitbucket.org URL');
9
+ const parts = url.pathname.split('/').filter(Boolean);
10
+ // https://bitbucket.org/{workspace}/workspace/projects/{projectKey}
11
+ if (parts.length >= 4 && parts[1] === 'workspace' && parts[2] === 'projects') {
12
+ return { source, workspace: parts[0], projectKey: parts[3].toUpperCase(), browseUrl: `https://bitbucket.org/${parts[0]}/workspace/projects/${parts[3].toUpperCase()}` };
13
+ }
14
+ } catch (e) {
15
+ if (e.message !== 'Invalid URL') throw e;
16
+ }
17
+ throw new Error(`Could not parse Bitbucket project URL: ${input}`);
18
+ }
19
+
20
+ function repositoriesApiUrl(workspace, projectKey, page = 1, pagelen = 100) {
21
+ const url = new URL(`https://bitbucket.org/!api/internal/workspaces/${encodeURIComponent(workspace)}/projects/${encodeURIComponent(projectKey)}/repositories`);
22
+ url.searchParams.set('page', String(page));
23
+ url.searchParams.set('pagelen', String(pagelen));
24
+ url.searchParams.set('sort', 'name');
25
+ url.searchParams.set('fields', '+values.parent');
26
+ return url.toString().replace('%2Bvalues.parent', '%2Bvalues.parent');
27
+ }
28
+
29
+ function cloneLinks(repo) {
30
+ const links = (((repo || {}).links || {}).clone || []);
31
+ const out = {};
32
+ for (const link of links) {
33
+ if (link && link.name && link.href) out[link.name] = link.href;
34
+ }
35
+ const fullName = repo.full_name || (repo.workspace && repo.slug ? `${repo.workspace.slug}/${repo.slug}` : '');
36
+ if (fullName) {
37
+ if (!out.ssh) out.ssh = `git@bitbucket.org:${fullName}.git`;
38
+ if (!out.https) out.https = `https://bitbucket.org/${fullName}.git`;
39
+ }
40
+ return out;
41
+ }
42
+
43
+ function normalizeRepo(repo) {
44
+ const project = repo.project || {};
45
+ const links = repo.links || {};
46
+ const htmlUrl = links.html && links.html.href || (repo.full_name ? `https://bitbucket.org/${repo.full_name}` : '');
47
+ return {
48
+ uuid: repo.uuid,
49
+ name: repo.name,
50
+ slug: repo.slug,
51
+ fullName: repo.full_name,
52
+ projectKey: project.key,
53
+ projectName: project.name,
54
+ isPrivate: repo.is_private,
55
+ scm: repo.scm,
56
+ mainBranch: repo.mainbranch && repo.mainbranch.name,
57
+ createdOn: repo.created_on,
58
+ updatedOn: repo.updated_on,
59
+ size: repo.size,
60
+ language: repo.language,
61
+ htmlUrl,
62
+ clone: cloneLinks(repo),
63
+ };
64
+ }
65
+
66
+ function safeName(s) {
67
+ return String(s || 'unknown').replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'unknown';
68
+ }
69
+
70
+ function repositoriesMarkdown(manifest) {
71
+ const lines = [];
72
+ lines.push(`# Bitbucket repositories: ${manifest.workspace} / ${manifest.projectKey}`);
73
+ lines.push('');
74
+ lines.push(`Fetched: ${manifest.fetchedAt}`);
75
+ lines.push(`Count: ${manifest.repositoryCount}`);
76
+ lines.push('');
77
+ lines.push('| Repository | Private | SSH clone | URL |');
78
+ lines.push('|---|---:|---|---|');
79
+ for (const repo of manifest.repositories) {
80
+ lines.push(`| ${repo.fullName || repo.name || ''} | ${repo.isPrivate ? 'yes' : 'no'} | \`${repo.clone && repo.clone.ssh || ''}\` | ${repo.htmlUrl || ''} |`);
81
+ }
82
+ lines.push('');
83
+ return lines.join('\n');
84
+ }
85
+
86
+ function cloneScript() {
87
+ return `#!/usr/bin/env bash
88
+ set -euo pipefail
89
+ TARGET_DIR="\${1:-repos}"
90
+ mkdir -p "$TARGET_DIR"
91
+ SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
92
+ while IFS= read -r url; do
93
+ [ -n "$url" ] || continue
94
+ name="$(basename "$url" .git)"
95
+ if [ -d "$TARGET_DIR/$name/.git" ]; then
96
+ echo "SKIP $name"
97
+ else
98
+ echo "CLONE $url -> $TARGET_DIR/$name"
99
+ git clone "$url" "$TARGET_DIR/$name"
100
+ fi
101
+ done < "$SCRIPT_DIR/clone-ssh.txt"
102
+ `;
103
+ }
104
+
105
+ module.exports = {
106
+ parseProjectInput,
107
+ repositoriesApiUrl,
108
+ cloneLinks,
109
+ normalizeRepo,
110
+ safeName,
111
+ repositoriesMarkdown,
112
+ cloneScript,
113
+ };