@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 +6 -0
- package/README.md +14 -2
- package/SECURITY.md +4 -4
- package/package.json +4 -3
- package/skills/bitbucket-browser-fetch/SKILL.md +66 -0
- package/skills/bitbucket-browser-fetch/references/distribution.md +25 -0
- package/skills/bitbucket-browser-fetch/references/usage.md +84 -0
- package/skills/bitbucket-browser-fetch/scripts/bitbucket-browser-fetch.js +344 -0
- package/skills/bitbucket-browser-fetch/scripts/lib.js +113 -0
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 `
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
+
};
|