@aholbreich/agent-skills 0.8.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 +20 -0
- package/README.md +64 -2
- package/SECURITY.md +4 -4
- package/bin/vendor.js +24 -0
- package/package.json +7 -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/atlassian-browser.js +261 -0
- package/skills/bitbucket-browser-fetch/scripts/bitbucket-browser-fetch.js +163 -0
- package/skills/bitbucket-browser-fetch/scripts/lib.js +113 -0
- 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
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fsp = require('fs/promises');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { createBrowserSession } = require('./atlassian-browser');
|
|
8
|
+
const {
|
|
9
|
+
parseProjectInput,
|
|
10
|
+
repositoriesApiUrl,
|
|
11
|
+
normalizeRepo,
|
|
12
|
+
safeName,
|
|
13
|
+
repositoriesMarkdown,
|
|
14
|
+
cloneScript,
|
|
15
|
+
} = require('./lib');
|
|
16
|
+
|
|
17
|
+
function usage() {
|
|
18
|
+
console.log(`Usage: bitbucket-browser-fetch <PROJECT_URL> [options]
|
|
19
|
+
|
|
20
|
+
Fetch Bitbucket Cloud project repository inventory through an authenticated browser session.
|
|
21
|
+
|
|
22
|
+
Options:
|
|
23
|
+
--workspace NAME Override workspace parsed from URL
|
|
24
|
+
--project KEY Override project key parsed from URL
|
|
25
|
+
--raw-dir DIR Output raw directory (default: BITBUCKET_RAW_DIR or ./raw)
|
|
26
|
+
--pagelen N Internal API page size (default: 100)
|
|
27
|
+
--wait SEC Wait time for login/session (default: 900)
|
|
28
|
+
--port PORT Chrome DevTools port (default: BITBUCKET_CHROME_DEBUG_PORT, ATLASSIAN_CHROME_DEBUG_PORT, or 9224)
|
|
29
|
+
--profile-dir DIR Chrome profile dir (default: BITBUCKET_CHROME_PROFILE, ATLASSIAN_CHROME_PROFILE, or ~/.local/share/bitbucket-browser-fetch-chrome)
|
|
30
|
+
--help Show this help
|
|
31
|
+
|
|
32
|
+
Examples:
|
|
33
|
+
bitbucket-browser-fetch 'https://bitbucket.org/myneva/workspace/projects/SWI' --raw-dir raw
|
|
34
|
+
`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const opts = {
|
|
38
|
+
projectUrl: '',
|
|
39
|
+
workspace: '',
|
|
40
|
+
projectKey: '',
|
|
41
|
+
rawDir: process.env.BITBUCKET_RAW_DIR || path.resolve(process.cwd(), 'raw'),
|
|
42
|
+
port: Number(process.env.BITBUCKET_CHROME_DEBUG_PORT || process.env.ATLASSIAN_CHROME_DEBUG_PORT || 9224),
|
|
43
|
+
waitSec: Number(process.env.BITBUCKET_FETCH_WAIT_SEC || 900),
|
|
44
|
+
profileDir: process.env.BITBUCKET_CHROME_PROFILE || process.env.ATLASSIAN_CHROME_PROFILE || path.join(os.homedir(), '.local/share/bitbucket-browser-fetch-chrome'),
|
|
45
|
+
pagelen: Number(process.env.BITBUCKET_PAGELEN || 100),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const args = process.argv.slice(2);
|
|
49
|
+
for (let i = 0; i < args.length; i++) {
|
|
50
|
+
const a = args[i];
|
|
51
|
+
if (a === '-h' || a === '--help') { usage(); process.exit(0); }
|
|
52
|
+
else if (a === '--workspace') opts.workspace = args[++i];
|
|
53
|
+
else if (a === '--project') opts.projectKey = args[++i].toUpperCase();
|
|
54
|
+
else if (a === '--raw-dir') opts.rawDir = args[++i];
|
|
55
|
+
else if (a === '--pagelen') opts.pagelen = Number(args[++i]);
|
|
56
|
+
else if (a === '--wait') opts.waitSec = Number(args[++i]);
|
|
57
|
+
else if (a === '--port') opts.port = Number(args[++i]);
|
|
58
|
+
else if (a === '--profile-dir') opts.profileDir = args[++i];
|
|
59
|
+
else if (!a.startsWith('-') && !opts.projectUrl) opts.projectUrl = a;
|
|
60
|
+
else { console.error(`Unknown argument: ${a}`); process.exit(2); }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!opts.projectUrl && (!opts.workspace || !opts.projectKey)) { usage(); process.exit(2); }
|
|
64
|
+
let project = null;
|
|
65
|
+
if (opts.projectUrl) project = parseProjectInput(opts.projectUrl);
|
|
66
|
+
else project = { source: '', workspace: opts.workspace, projectKey: opts.projectKey, browseUrl: `https://bitbucket.org/${opts.workspace}/workspace/projects/${opts.projectKey}` };
|
|
67
|
+
if (opts.workspace) project.workspace = opts.workspace;
|
|
68
|
+
if (opts.projectKey) project.projectKey = opts.projectKey.toUpperCase();
|
|
69
|
+
project.browseUrl = `https://bitbucket.org/${project.workspace}/workspace/projects/${project.projectKey}`;
|
|
70
|
+
opts.rawDir = path.resolve(opts.rawDir);
|
|
71
|
+
opts.pagelen = Math.min(100, Math.max(1, opts.pagelen || 100));
|
|
72
|
+
|
|
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),
|
|
84
|
+
});
|
|
85
|
+
return session;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function fetchJson(url, cookie) {
|
|
89
|
+
return getSession().fetchJson(url, cookie, { accept: 'application/json' });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function verifyBitbucketSession(cookie) {
|
|
93
|
+
if (!cookie) return { ok: false, message: 'no Bitbucket cookies yet' };
|
|
94
|
+
const url = `https://bitbucket.org/!api/internal/menu/project/${encodeURIComponent(project.workspace)}/${encodeURIComponent(project.projectKey)}`;
|
|
95
|
+
const result = await fetchJson(url, cookie);
|
|
96
|
+
if (result.status === 200 && result.json) return { ok: true, url };
|
|
97
|
+
if (result.status === 401 || result.status === 403 || result.status === 404) {
|
|
98
|
+
return { ok: false, message: `not authenticated or no project access (${result.status} from ${url})` };
|
|
99
|
+
}
|
|
100
|
+
return { ok: false, message: `session probe HTTP ${result.status} from ${url}` };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function getCookieWithWait() {
|
|
104
|
+
return getSession().getCookieWithWait(project.browseUrl);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function fetchRepositories(cookie) {
|
|
108
|
+
const pages = [];
|
|
109
|
+
const repos = [];
|
|
110
|
+
let page = 1;
|
|
111
|
+
let nextUrl = repositoriesApiUrl(project.workspace, project.projectKey, page, opts.pagelen);
|
|
112
|
+
while (nextUrl) {
|
|
113
|
+
const result = await fetchJson(nextUrl, cookie);
|
|
114
|
+
if (result.status !== 200 || !result.json || !Array.isArray(result.json.values)) {
|
|
115
|
+
throw new Error(`Repository list failed HTTP ${result.status}: ${(result.text || '').slice(0, 500).replace(/\s+/g, ' ')}`);
|
|
116
|
+
}
|
|
117
|
+
pages.push(result.json);
|
|
118
|
+
repos.push(...result.json.values.map(normalizeRepo));
|
|
119
|
+
console.log(`Fetched Bitbucket repositories page ${result.json.page || page}: ${result.json.values.length} repo(s)`);
|
|
120
|
+
nextUrl = result.json.next || '';
|
|
121
|
+
page += 1;
|
|
122
|
+
}
|
|
123
|
+
return { pages, repos };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function main() {
|
|
127
|
+
await fsp.mkdir(opts.rawDir, { recursive: true });
|
|
128
|
+
const cookie = await getCookieWithWait();
|
|
129
|
+
const { pages, repos } = await fetchRepositories(cookie);
|
|
130
|
+
const outDir = path.join(opts.rawDir, 'bitbucket', safeName(project.workspace), 'projects', safeName(project.projectKey));
|
|
131
|
+
await fsp.mkdir(path.join(outDir, 'pages'), { recursive: true });
|
|
132
|
+
|
|
133
|
+
const manifest = {
|
|
134
|
+
fetchedAt: new Date().toISOString(),
|
|
135
|
+
source: project.source || project.browseUrl,
|
|
136
|
+
browseUrl: project.browseUrl,
|
|
137
|
+
workspace: project.workspace,
|
|
138
|
+
projectKey: project.projectKey,
|
|
139
|
+
repositoryCount: repos.length,
|
|
140
|
+
repositories: repos,
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
await fsp.writeFile(path.join(outDir, 'repositories.json'), JSON.stringify(manifest, null, 2));
|
|
144
|
+
await fsp.writeFile(path.join(outDir, 'repositories.md'), repositoriesMarkdown(manifest));
|
|
145
|
+
await fsp.writeFile(path.join(outDir, 'clone-ssh.txt'), repos.map(r => r.clone && r.clone.ssh).filter(Boolean).join('\n') + '\n');
|
|
146
|
+
await fsp.writeFile(path.join(outDir, 'clone-https.txt'), repos.map(r => r.clone && r.clone.https).filter(Boolean).join('\n') + '\n');
|
|
147
|
+
await fsp.writeFile(path.join(outDir, 'clone-ssh.sh'), cloneScript(), { mode: 0o755 });
|
|
148
|
+
for (let i = 0; i < pages.length; i++) {
|
|
149
|
+
await fsp.writeFile(path.join(outDir, 'pages', `repositories-page-${i + 1}.json`), JSON.stringify(pages[i], null, 2));
|
|
150
|
+
}
|
|
151
|
+
const runMeta = { fetchedAt: manifest.fetchedAt, workspace: project.workspace, projectKey: project.projectKey, rawDir: outDir, repositoryCount: repos.length };
|
|
152
|
+
await fsp.writeFile(path.join(outDir, 'bitbucket-browser-fetch-run.json'), JSON.stringify(runMeta, null, 2));
|
|
153
|
+
|
|
154
|
+
console.log(`\nFetched ${repos.length} Bitbucket repos for ${project.workspace}/${project.projectKey}`);
|
|
155
|
+
console.log(`Saved ${path.join(outDir, 'repositories.json')}`);
|
|
156
|
+
console.log(`SSH clone list: ${path.join(outDir, 'clone-ssh.txt')}`);
|
|
157
|
+
for (const repo of repos) console.log(`- ${repo.fullName || repo.name}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
main().catch(err => {
|
|
161
|
+
console.error(`\nERROR: ${err.stack || err.message}`);
|
|
162
|
+
process.exit(1);
|
|
163
|
+
});
|
|
@@ -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
|
+
};
|
|
@@ -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
|
+
};
|