@aholbreich/agent-skills 0.6.0 → 0.7.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 +12 -0
- package/README.md +26 -1
- package/SECURITY.md +4 -3
- package/package.json +4 -3
- package/skills/confluence-browser-fetch/scripts/confluence-browser-fetch.js +27 -6
- package/skills/confluence-update/SKILL.md +133 -0
- package/skills/confluence-update/references/distribution.md +24 -0
- package/skills/confluence-update/references/usage.md +138 -0
- package/skills/confluence-update/scripts/confluence-update.js +541 -0
- package/skills/confluence-update/scripts/lib.js +197 -0
- package/skills/jira-browser-fetch/scripts/jira-browser-fetch.js +28 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.0 - 2026-05-07
|
|
4
|
+
|
|
5
|
+
Added:
|
|
6
|
+
|
|
7
|
+
- New `confluence-update` skill for dry-run-first Confluence page updates, agent-owned block replacement, simple Markdown-to-storage conversion, and page creation through authenticated browser sessions.
|
|
8
|
+
|
|
9
|
+
## 0.6.1 - 2026-05-07
|
|
10
|
+
|
|
11
|
+
Fixed:
|
|
12
|
+
|
|
13
|
+
- Browser fetchers no longer open duplicate target tabs when reusing DevTools during bulk Jira/Confluence runs.
|
|
14
|
+
|
|
3
15
|
## 0.6.0 - 2026-05-07
|
|
4
16
|
|
|
5
17
|
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
|
|
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.
|
|
6
6
|
|
|
7
7
|
## Skills
|
|
8
8
|
|
|
@@ -10,6 +10,7 @@ This repository is a pure skills package. It currently contains browser-authenti
|
|
|
10
10
|
|---|---|
|
|
11
11
|
| [`jira-browser-fetch`](skills/jira-browser-fetch/) | Fetch Jira issue JSON, rendered HTML/XML, linked/referenced issues, Jira Software board backlogs, JQL result sets, and attachments through an authenticated Chrome session. |
|
|
12
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
|
+
| [`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. |
|
|
13
14
|
|
|
14
15
|
## Compatibility
|
|
15
16
|
|
|
@@ -175,6 +176,7 @@ If installed globally via npm, the package exposes:
|
|
|
175
176
|
agent-skills
|
|
176
177
|
jira-browser-fetch
|
|
177
178
|
confluence-browser-fetch
|
|
179
|
+
confluence-update
|
|
178
180
|
```
|
|
179
181
|
|
|
180
182
|
## Reuse one Atlassian browser login
|
|
@@ -188,6 +190,29 @@ export ATLASSIAN_CHROME_DEBUG_PORT=9223
|
|
|
188
190
|
|
|
189
191
|
Skill-specific variables such as `JIRA_CHROME_PROFILE` or `CONFLUENCE_CHROME_PROFILE` still override the shared profile when needed.
|
|
190
192
|
|
|
193
|
+
## Confluence update examples
|
|
194
|
+
|
|
195
|
+
Dry-run an agent-owned block replacement from Markdown:
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
confluence-update replace-block 123456 \
|
|
199
|
+
--site https://example.atlassian.net \
|
|
200
|
+
--marker agent-summary \
|
|
201
|
+
--file ./summary.md \
|
|
202
|
+
--representation markdown
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Apply only after reviewing `raw/confluence-updates/...`:
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
confluence-update replace-block 123456 \
|
|
209
|
+
--site https://example.atlassian.net \
|
|
210
|
+
--marker agent-summary \
|
|
211
|
+
--file ./summary.md \
|
|
212
|
+
--representation markdown \
|
|
213
|
+
--apply
|
|
214
|
+
```
|
|
215
|
+
|
|
191
216
|
## Jira examples
|
|
192
217
|
|
|
193
218
|
Fetch one issue:
|
package/SECURITY.md
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
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
|
|
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`.
|
|
6
6
|
|
|
7
7
|
## Browser authentication model
|
|
8
8
|
|
|
9
|
-
The Jira and Confluence
|
|
9
|
+
The Jira and Confluence 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,
|
|
@@ -20,10 +20,11 @@ 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 or attachments to a public repository.
|
|
23
|
+
- Do not commit fetched Jira/Confluence exports, update audit files, 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.
|
|
27
|
+
- `confluence-update` is dry-run by default; review audit files before re-running with `--apply`.
|
|
27
28
|
- The default attachment download cap is `5mb`; skipped large attachments are still referenced in `attachments.json`.
|
|
28
29
|
|
|
29
30
|
## Attachment size limits
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aholbreich/agent-skills",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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",
|
|
@@ -37,10 +37,11 @@
|
|
|
37
37
|
"bin": {
|
|
38
38
|
"agent-skills": "bin/agent-skills.js",
|
|
39
39
|
"jira-browser-fetch": "skills/jira-browser-fetch/scripts/jira-browser-fetch.js",
|
|
40
|
-
"confluence-browser-fetch": "skills/confluence-browser-fetch/scripts/confluence-browser-fetch.js"
|
|
40
|
+
"confluence-browser-fetch": "skills/confluence-browser-fetch/scripts/confluence-browser-fetch.js",
|
|
41
|
+
"confluence-update": "skills/confluence-update/scripts/confluence-update.js"
|
|
41
42
|
},
|
|
42
43
|
"scripts": {
|
|
43
|
-
"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",
|
|
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",
|
|
44
45
|
"test": "node --test",
|
|
45
46
|
"ci": "npm run check && npm test && npm pack --dry-run",
|
|
46
47
|
"pack:dry": "npm pack --dry-run",
|
|
@@ -131,6 +131,18 @@ async function openDevtoolsTab(url) {
|
|
|
131
131
|
return false;
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
async function hasDevtoolsTabForWiki(url) {
|
|
135
|
+
if (!url) return false;
|
|
136
|
+
const host = new URL(url).host;
|
|
137
|
+
const list = await endpoint('/json/list');
|
|
138
|
+
return list.some(t => t.type === 'page' && t.url && (() => {
|
|
139
|
+
try {
|
|
140
|
+
const tabUrl = new URL(t.url);
|
|
141
|
+
return tabUrl.host === host && tabUrl.pathname.startsWith('/wiki');
|
|
142
|
+
} catch { return false; }
|
|
143
|
+
})());
|
|
144
|
+
}
|
|
145
|
+
|
|
134
146
|
function isExecutable(file) {
|
|
135
147
|
try { fs.accessSync(file, fs.constants.X_OK); return true; } catch { return false; }
|
|
136
148
|
}
|
|
@@ -198,9 +210,14 @@ async function ensureBrowser(openUrl) {
|
|
|
198
210
|
console.log(`Reusing Chrome DevTools on port ${opts.port}`);
|
|
199
211
|
const targetUrl = openUrl || wikiBase;
|
|
200
212
|
if (targetUrl) {
|
|
201
|
-
const
|
|
202
|
-
if (
|
|
203
|
-
|
|
213
|
+
const hasTab = await hasDevtoolsTabForWiki(targetUrl);
|
|
214
|
+
if (hasTab) {
|
|
215
|
+
console.log(`Found existing Confluence tab for ${new URL(targetUrl).host}; not opening another tab.`);
|
|
216
|
+
} else {
|
|
217
|
+
const opened = await openDevtoolsTab(targetUrl);
|
|
218
|
+
if (opened) console.log(`Opened target URL in reused browser: ${targetUrl}`);
|
|
219
|
+
else console.warn(`Could not open target URL through DevTools; continuing with existing tabs.`);
|
|
220
|
+
}
|
|
204
221
|
}
|
|
205
222
|
}
|
|
206
223
|
await waitDevtools();
|
|
@@ -346,16 +363,20 @@ async function getCookieWithWait(openUrl) {
|
|
|
346
363
|
const cookie = await getCookieHeader();
|
|
347
364
|
const session = await verifyConfluenceSession(cookie);
|
|
348
365
|
if (session.ok) {
|
|
349
|
-
process.stdout.write('\n');
|
|
366
|
+
if (process.stdout.isTTY) process.stdout.write('\n');
|
|
350
367
|
console.log(`Authenticated Confluence session verified via ${session.url}`);
|
|
351
368
|
return cookie;
|
|
352
369
|
}
|
|
353
370
|
last = session.message;
|
|
354
371
|
} catch (e) { last = e.message; }
|
|
355
|
-
process.stdout.
|
|
372
|
+
if (process.stdout.isTTY) {
|
|
373
|
+
process.stdout.write(`\r${new Date().toLocaleTimeString()} ${last.padEnd(120).slice(0, 120)}`);
|
|
374
|
+
} else if (Date.now() - deadline + opts.waitSec * 1000 < 4000) {
|
|
375
|
+
console.log(`Waiting up to ${opts.waitSec}s for Confluence session...`);
|
|
376
|
+
}
|
|
356
377
|
await sleep(3000);
|
|
357
378
|
}
|
|
358
|
-
process.stdout.write('\n');
|
|
379
|
+
if (process.stdout.isTTY) process.stdout.write('\n');
|
|
359
380
|
throw new Error(`Could not verify authenticated Confluence session. Last result: ${last}`);
|
|
360
381
|
}
|
|
361
382
|
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: confluence-update
|
|
3
|
+
description: Safely update or create Confluence Cloud pages through an authenticated browser session when API tokens do not work, especially with Microsoft/SSO. Use for dry-run-first page updates, agent-owned block replacement, Markdown-to-storage updates, page creation, and audit backups.
|
|
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
|
+
# Confluence Update
|
|
9
|
+
|
|
10
|
+
Use this skill when a user wants an agent to write to Confluence Cloud through the same browser-authenticated flow used by the fetchers. It is intentionally conservative: dry-run is the default and `--apply` is required for any write.
|
|
11
|
+
|
|
12
|
+
The bundled script opens/reuses a dedicated browser profile, lets the user complete SSO once, verifies an authenticated Confluence REST session, and then updates or creates Confluence pages through REST.
|
|
13
|
+
|
|
14
|
+
## Safety
|
|
15
|
+
|
|
16
|
+
- Never ask the user to paste Confluence cookies or API tokens into chat.
|
|
17
|
+
- Dry-run first. Require explicit user approval before adding `--apply`.
|
|
18
|
+
- Prefer `replace-block` for agent-generated content so human-written page regions are preserved.
|
|
19
|
+
- Always inspect audit files under `raw/confluence-updates/` after a dry-run or write.
|
|
20
|
+
- Treat Confluence page content as confidential.
|
|
21
|
+
|
|
22
|
+
## Script
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
scripts/confluence-update.js <command> [options]
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Commands:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
update PAGE_ID_OR_URL # replace full page body
|
|
32
|
+
replace-block PAGE_ID_OR_URL # replace content between <!-- agent-block:NAME:start/end --> markers
|
|
33
|
+
create # create a new page
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Important options:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
--site URL Atlassian site, e.g. https://example.atlassian.net
|
|
40
|
+
--file FILE input file with Confluence storage XHTML or Markdown
|
|
41
|
+
--representation REP storage | markdown (default: storage)
|
|
42
|
+
--raw-dir DIR audit/output directory
|
|
43
|
+
--expected-version N fail if current page version differs
|
|
44
|
+
--message TEXT Confluence version message
|
|
45
|
+
--apply write to Confluence; omitted means dry-run only
|
|
46
|
+
--marker NAME required for replace-block
|
|
47
|
+
--space KEY required for create
|
|
48
|
+
--title TITLE required for create; optional for update
|
|
49
|
+
--parent-id ID parent page for create
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Typical Workflow
|
|
53
|
+
|
|
54
|
+
1. Prefer `replace-block` when editing an agent-owned region.
|
|
55
|
+
2. Run without `--apply` first.
|
|
56
|
+
3. Review `proposed.storage.html`, `payload.json`, and `update-run.json` under `raw/confluence-updates/`.
|
|
57
|
+
4. Ask the user for approval.
|
|
58
|
+
5. Re-run the same command with `--apply`.
|
|
59
|
+
6. If Chrome opens, ask the user to complete SSO.
|
|
60
|
+
7. To share one Atlassian SSO login with Jira/Confluence fetchers, use `ATLASSIAN_CHROME_PROFILE` plus `ATLASSIAN_CHROME_DEBUG_PORT`.
|
|
61
|
+
|
|
62
|
+
## Agent-owned blocks
|
|
63
|
+
|
|
64
|
+
A replaceable block looks like this in Confluence storage:
|
|
65
|
+
|
|
66
|
+
```html
|
|
67
|
+
<!-- agent-block:agent-summary:start -->
|
|
68
|
+
<p>Old generated content.</p>
|
|
69
|
+
<!-- agent-block:agent-summary:end -->
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Then run:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
scripts/confluence-update.js replace-block 123456 \
|
|
76
|
+
--site https://example.atlassian.net \
|
|
77
|
+
--marker agent-summary \
|
|
78
|
+
--file ./summary.md \
|
|
79
|
+
--representation markdown
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Add `--apply` only after dry-run review.
|
|
83
|
+
|
|
84
|
+
## Examples
|
|
85
|
+
|
|
86
|
+
Dry-run a full-page storage update:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
scripts/confluence-update.js update 123456 \
|
|
90
|
+
--site https://example.atlassian.net \
|
|
91
|
+
--file ./page.storage.html
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Apply with version protection:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
scripts/confluence-update.js update 123456 \
|
|
98
|
+
--site https://example.atlassian.net \
|
|
99
|
+
--file ./page.storage.html \
|
|
100
|
+
--expected-version 17 \
|
|
101
|
+
--message 'Update architecture notes' \
|
|
102
|
+
--apply
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Create a page from Markdown:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
scripts/confluence-update.js create \
|
|
109
|
+
--site https://example.atlassian.net \
|
|
110
|
+
--space ABC \
|
|
111
|
+
--parent-id 123456 \
|
|
112
|
+
--title 'Architecture Notes' \
|
|
113
|
+
--file ./page.md \
|
|
114
|
+
--representation markdown
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Output Layout
|
|
118
|
+
|
|
119
|
+
Each dry-run or write creates an audit directory:
|
|
120
|
+
|
|
121
|
+
```text
|
|
122
|
+
raw/confluence-updates/<page-or-create>-<timestamp>/
|
|
123
|
+
├── before.page.json # existing page for update/replace-block
|
|
124
|
+
├── before.storage.html # existing storage body for update/replace-block
|
|
125
|
+
├── proposed.storage.html # generated replacement body
|
|
126
|
+
├── payload.json # REST payload that would be sent
|
|
127
|
+
├── after.page.json # only after successful --apply
|
|
128
|
+
└── update-run.json # command metadata
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## References
|
|
132
|
+
|
|
133
|
+
- [Usage reference](references/usage.md)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Confluence Update Distribution
|
|
2
|
+
|
|
3
|
+
This skill is distributed as part of `@aholbreich/agent-skills`.
|
|
4
|
+
|
|
5
|
+
Directory layout:
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
confluence-update/
|
|
9
|
+
├── SKILL.md
|
|
10
|
+
├── references/
|
|
11
|
+
│ └── usage.md
|
|
12
|
+
└── scripts/
|
|
13
|
+
├── confluence-update.js
|
|
14
|
+
└── lib.js
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Use directly by path or install a convenience symlink:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
mkdir -p ~/.local/bin
|
|
21
|
+
ln -sf ~/.pi/agent/skills/confluence-update/scripts/confluence-update.js ~/.local/bin/confluence-update
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The package also exposes a `confluence-update` npm bin when installed globally.
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# Confluence Update Usage
|
|
2
|
+
|
|
3
|
+
## Why Browser Update?
|
|
4
|
+
|
|
5
|
+
Some Confluence Cloud organizations use Microsoft/SSO and make API-token auth difficult. This updater avoids pasted secrets by:
|
|
6
|
+
|
|
7
|
+
1. Launching/reusing a dedicated Chromium-compatible browser profile.
|
|
8
|
+
2. Letting the user complete normal Atlassian SSO in the browser.
|
|
9
|
+
3. Reading Atlassian cookies through local Chrome DevTools.
|
|
10
|
+
4. Verifying those cookies represent an authenticated Confluence REST session.
|
|
11
|
+
5. Writing through Confluence REST only after `--apply`.
|
|
12
|
+
|
|
13
|
+
## Safety Model
|
|
14
|
+
|
|
15
|
+
Dry-run is the default. Without `--apply`, the script writes audit/proposal files locally but does not update Confluence.
|
|
16
|
+
|
|
17
|
+
Always review:
|
|
18
|
+
|
|
19
|
+
- `proposed.storage.html`
|
|
20
|
+
- `payload.json`
|
|
21
|
+
- `update-run.json`
|
|
22
|
+
|
|
23
|
+
For existing pages, the script also stores `before.page.json` and `before.storage.html`.
|
|
24
|
+
|
|
25
|
+
## Common Commands
|
|
26
|
+
|
|
27
|
+
Dry-run full-page update with native Confluence storage XHTML:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
scripts/confluence-update.js update 123456 \
|
|
31
|
+
--site https://example.atlassian.net \
|
|
32
|
+
--file ./page.storage.html
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Apply after review:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
scripts/confluence-update.js update 123456 \
|
|
39
|
+
--site https://example.atlassian.net \
|
|
40
|
+
--file ./page.storage.html \
|
|
41
|
+
--expected-version 17 \
|
|
42
|
+
--message 'Update architecture notes' \
|
|
43
|
+
--apply
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Replace an agent-owned block from Markdown:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
scripts/confluence-update.js replace-block 123456 \
|
|
50
|
+
--site https://example.atlassian.net \
|
|
51
|
+
--marker agent-summary \
|
|
52
|
+
--file ./summary.md \
|
|
53
|
+
--representation markdown
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Create a page from Markdown:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
scripts/confluence-update.js create \
|
|
60
|
+
--site https://example.atlassian.net \
|
|
61
|
+
--space ABC \
|
|
62
|
+
--parent-id 123456 \
|
|
63
|
+
--title 'Architecture Notes' \
|
|
64
|
+
--file ./page.md \
|
|
65
|
+
--representation markdown
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Agent-owned Blocks
|
|
69
|
+
|
|
70
|
+
Use block replacement for LLM-generated content. It protects human-written parts of the page.
|
|
71
|
+
|
|
72
|
+
Page storage must contain markers:
|
|
73
|
+
|
|
74
|
+
```html
|
|
75
|
+
<!-- agent-block:release-notes:start -->
|
|
76
|
+
<p>Old generated content.</p>
|
|
77
|
+
<!-- agent-block:release-notes:end -->
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Then update only that region:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
scripts/confluence-update.js replace-block 123456 \
|
|
84
|
+
--marker release-notes \
|
|
85
|
+
--file ./release-notes.md \
|
|
86
|
+
--representation markdown
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
If the marker is missing, the command fails. It does not insert content into an unmarked page.
|
|
90
|
+
|
|
91
|
+
## Representations
|
|
92
|
+
|
|
93
|
+
| Representation | Meaning |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `storage` | Native Confluence storage XHTML. Best for exact page updates and preserving advanced Confluence structures. |
|
|
96
|
+
| `markdown` | Small built-in Markdown subset converted to storage XHTML. Best for agent-owned blocks and simple new pages. |
|
|
97
|
+
|
|
98
|
+
The Markdown converter is intentionally simple: headings, paragraphs, unordered/ordered lists, links, emphasis, inline code, and fenced code blocks. For complex macros/layouts, use `storage`.
|
|
99
|
+
|
|
100
|
+
## Environment Variables
|
|
101
|
+
|
|
102
|
+
| Variable | Meaning |
|
|
103
|
+
|---|---|
|
|
104
|
+
| `CONFLUENCE_SITE` | Default Atlassian site, e.g. `https://example.atlassian.net` |
|
|
105
|
+
| `CONFLUENCE_UPDATE_RAW_DIR` / `CONFLUENCE_RAW_DIR` | Audit/output raw directory |
|
|
106
|
+
| `CONFLUENCE_CHROME_DEBUG_PORT` | Chrome DevTools port, default `9224`; overrides `ATLASSIAN_CHROME_DEBUG_PORT` |
|
|
107
|
+
| `ATLASSIAN_CHROME_DEBUG_PORT` | Shared Chrome DevTools port for Jira/Confluence tools. If only `ATLASSIAN_CHROME_PROFILE` is set, Confluence update defaults to shared port `9223`. |
|
|
108
|
+
| `CONFLUENCE_UPDATE_WAIT_SEC` / `CONFLUENCE_FETCH_WAIT_SEC` | Wait timeout, default `900` |
|
|
109
|
+
| `CONFLUENCE_CHROME_PROFILE` | Dedicated Chrome profile dir; overrides `ATLASSIAN_CHROME_PROFILE`. By default this uses the same profile as `confluence-browser-fetch`. |
|
|
110
|
+
| `ATLASSIAN_CHROME_PROFILE` | Shared browser profile dir for Jira, Confluence fetch, and Confluence update tools |
|
|
111
|
+
| `CHROME` / `CHROMIUM` | Browser executable path override |
|
|
112
|
+
|
|
113
|
+
## Shared Atlassian SSO Session
|
|
114
|
+
|
|
115
|
+
To reuse one Atlassian browser login across Jira fetch, Confluence fetch, and Confluence update:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
export ATLASSIAN_CHROME_PROFILE="$HOME/.local/share/atlassian-browser-fetch-chrome"
|
|
119
|
+
export ATLASSIAN_CHROME_DEBUG_PORT=9223
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Troubleshooting
|
|
123
|
+
|
|
124
|
+
### Dry-run did not update the page
|
|
125
|
+
|
|
126
|
+
That is expected. Add `--apply` only after reviewing the audit files.
|
|
127
|
+
|
|
128
|
+
### Version mismatch
|
|
129
|
+
|
|
130
|
+
The page changed after the agent prepared the update. Refetch/review the page and rerun with the new version.
|
|
131
|
+
|
|
132
|
+
### Marker block not found
|
|
133
|
+
|
|
134
|
+
`replace-block` only edits explicitly marked regions. Add the marker block manually or use full-page `update` after review.
|
|
135
|
+
|
|
136
|
+
### Authentication waits forever
|
|
137
|
+
|
|
138
|
+
Complete SSO in the opened browser. Login-page cookies are not enough; the script waits until a Confluence REST probe succeeds.
|
|
@@ -0,0 +1,541 @@
|
|
|
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 lib = require('./lib');
|
|
10
|
+
const {
|
|
11
|
+
safeName,
|
|
12
|
+
extractPageId,
|
|
13
|
+
renderContent,
|
|
14
|
+
replaceMarkedBlock,
|
|
15
|
+
} = lib;
|
|
16
|
+
|
|
17
|
+
function usage() {
|
|
18
|
+
console.log(`Usage: confluence-update <command> [options]
|
|
19
|
+
|
|
20
|
+
Safely update or create Confluence Cloud pages through an authenticated browser session.
|
|
21
|
+
Dry-run is the default; pass --apply to write to Confluence.
|
|
22
|
+
|
|
23
|
+
Commands:
|
|
24
|
+
update PAGE_ID_OR_URL Replace an existing page body
|
|
25
|
+
replace-block PAGE_ID_OR_URL Replace only a marked agent-owned block
|
|
26
|
+
replace-text PAGE_ID_OR_URL Replace an exact matched string in the page
|
|
27
|
+
replace-element PAGE_ID_OR_URL Replace an element by its local-id
|
|
28
|
+
create Create a new page
|
|
29
|
+
|
|
30
|
+
Common options:
|
|
31
|
+
--site URL Atlassian site base URL (or CONFLUENCE_SITE), e.g. https://example.atlassian.net
|
|
32
|
+
--file FILE Input file containing storage XHTML or Markdown
|
|
33
|
+
--representation REP storage | markdown (default: storage)
|
|
34
|
+
--raw-dir DIR Output/audit dir (default: CONFLUENCE_UPDATE_RAW_DIR, CONFLUENCE_RAW_DIR, or ./raw)
|
|
35
|
+
--message TEXT Version message (default: Updated by confluence-update)
|
|
36
|
+
--minor-edit Mark update as minor edit (default)
|
|
37
|
+
--major-edit Do not mark update as minor edit
|
|
38
|
+
--expected-version N|auto Fail if current page version is not N. Use 'auto' to always overwrite (default: null)
|
|
39
|
+
--apply Actually write. Without this, only dry-run/audit files are written
|
|
40
|
+
--wait SEC Wait time for SSO/session (default: 900)
|
|
41
|
+
--port PORT Chrome DevTools port (default: CONFLUENCE_CHROME_DEBUG_PORT, ATLASSIAN_CHROME_DEBUG_PORT, or 9224)
|
|
42
|
+
--profile-dir DIR Chrome profile dir (default: CONFLUENCE_CHROME_PROFILE, ATLASSIAN_CHROME_PROFILE, or ~/.local/share/confluence-browser-fetch-chrome)
|
|
43
|
+
|
|
44
|
+
Update options:
|
|
45
|
+
--title TITLE Override page title while updating
|
|
46
|
+
|
|
47
|
+
replace-block options:
|
|
48
|
+
--marker NAME Required marker name, e.g. agent-summary for <!-- agent-block:agent-summary:start -->
|
|
49
|
+
|
|
50
|
+
replace-text options:
|
|
51
|
+
--match TEXT Required exact string to find and replace
|
|
52
|
+
|
|
53
|
+
replace-element options:
|
|
54
|
+
--local-id ID Required local-id attribute value of the element to replace
|
|
55
|
+
|
|
56
|
+
Create options:
|
|
57
|
+
--space KEY Required Confluence space key
|
|
58
|
+
--title TITLE Required page title
|
|
59
|
+
--parent-id ID Parent page id. Required unless --allow-root is passed
|
|
60
|
+
--allow-root Allow creating a root page without parent-id
|
|
61
|
+
|
|
62
|
+
Examples:
|
|
63
|
+
confluence-update update 123456 --site https://example.atlassian.net --file page.storage.html --apply
|
|
64
|
+
confluence-update replace-block 123456 --marker agent-summary --file summary.md --representation markdown --apply
|
|
65
|
+
confluence-update create --site https://example.atlassian.net --space ABC --parent-id 123456 --title 'New Page' --file page.md --representation markdown --apply
|
|
66
|
+
`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const opts = {
|
|
70
|
+
command: '',
|
|
71
|
+
pageInput: '',
|
|
72
|
+
site: process.env.CONFLUENCE_SITE || '',
|
|
73
|
+
rawDir: process.env.CONFLUENCE_UPDATE_RAW_DIR || process.env.CONFLUENCE_RAW_DIR || path.resolve(process.cwd(), 'raw'),
|
|
74
|
+
port: Number(process.env.CONFLUENCE_CHROME_DEBUG_PORT || process.env.ATLASSIAN_CHROME_DEBUG_PORT || (process.env.ATLASSIAN_CHROME_PROFILE ? 9223 : 9224)),
|
|
75
|
+
waitSec: Number(process.env.CONFLUENCE_UPDATE_WAIT_SEC || process.env.CONFLUENCE_FETCH_WAIT_SEC || 900),
|
|
76
|
+
profileDir: process.env.CONFLUENCE_CHROME_PROFILE || process.env.ATLASSIAN_CHROME_PROFILE || path.join(os.homedir(), '.local/share/confluence-browser-fetch-chrome'),
|
|
77
|
+
file: '',
|
|
78
|
+
representation: 'storage',
|
|
79
|
+
title: '',
|
|
80
|
+
message: 'Updated by confluence-update',
|
|
81
|
+
minorEdit: true,
|
|
82
|
+
expectedVersion: null,
|
|
83
|
+
apply: false,
|
|
84
|
+
marker: '',
|
|
85
|
+
matchText: '',
|
|
86
|
+
localId: '',
|
|
87
|
+
space: '',
|
|
88
|
+
parentId: '',
|
|
89
|
+
allowRoot: false,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const args = process.argv.slice(2);
|
|
93
|
+
if (!args.length || args.includes('-h') || args.includes('--help')) { usage(); process.exit(0); }
|
|
94
|
+
opts.command = args.shift();
|
|
95
|
+
if (!['update', 'replace-block', 'replace-text', 'replace-element', 'create'].includes(opts.command)) {
|
|
96
|
+
console.error(`Unknown command: ${opts.command}`);
|
|
97
|
+
usage();
|
|
98
|
+
process.exit(2);
|
|
99
|
+
}
|
|
100
|
+
if (opts.command !== 'create') opts.pageInput = args.shift() || '';
|
|
101
|
+
|
|
102
|
+
for (let i = 0; i < args.length; i++) {
|
|
103
|
+
const a = args[i];
|
|
104
|
+
if (a === '--site') opts.site = args[++i];
|
|
105
|
+
else if (a === '--raw-dir') opts.rawDir = args[++i];
|
|
106
|
+
else if (a === '--file') opts.file = args[++i];
|
|
107
|
+
else if (a === '--representation') opts.representation = args[++i];
|
|
108
|
+
else if (a === '--title') opts.title = args[++i];
|
|
109
|
+
else if (a === '--message') opts.message = args[++i];
|
|
110
|
+
else if (a === '--minor-edit') opts.minorEdit = true;
|
|
111
|
+
else if (a === '--major-edit') opts.minorEdit = false;
|
|
112
|
+
else if (a === '--expected-version') opts.expectedVersion = args[++i] === 'auto' ? 'auto' : Number(args[i]);
|
|
113
|
+
else if (a === '--apply') opts.apply = true;
|
|
114
|
+
else if (a === '--wait') opts.waitSec = Number(args[++i]);
|
|
115
|
+
else if (a === '--port') opts.port = Number(args[++i]);
|
|
116
|
+
else if (a === '--profile-dir') opts.profileDir = args[++i];
|
|
117
|
+
else if (a === '--marker') opts.marker = args[++i];
|
|
118
|
+
else if (a === '--match') opts.matchText = args[++i];
|
|
119
|
+
else if (a === '--local-id') opts.localId = args[++i];
|
|
120
|
+
else if (a === '--space') opts.space = args[++i];
|
|
121
|
+
else if (a === '--parent-id') opts.parentId = args[++i];
|
|
122
|
+
else if (a === '--allow-root') opts.allowRoot = true;
|
|
123
|
+
else { console.error(`Unknown argument: ${a}`); process.exit(2); }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
opts.site = opts.site.replace(/\/$/, '');
|
|
127
|
+
opts.rawDir = path.resolve(opts.rawDir);
|
|
128
|
+
const wikiBase = opts.site ? `${opts.site}/wiki` : '';
|
|
129
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
130
|
+
|
|
131
|
+
function failUsage(message) {
|
|
132
|
+
console.error(message);
|
|
133
|
+
process.exit(2);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!opts.site) failUsage('Missing Atlassian site. Pass --site https://example.atlassian.net or set CONFLUENCE_SITE.');
|
|
137
|
+
if (!opts.file) failUsage('Missing --file input.');
|
|
138
|
+
if (opts.command !== 'create' && !opts.pageInput) failUsage(`Missing page id or URL for ${opts.command}.`);
|
|
139
|
+
if (opts.command === 'replace-block' && !opts.marker) failUsage('replace-block requires --marker NAME.');
|
|
140
|
+
if (opts.command === 'replace-text' && !opts.matchText) failUsage('replace-text requires --match TEXT.');
|
|
141
|
+
if (opts.command === 'replace-element' && !opts.localId) failUsage('replace-element requires --local-id ID.');
|
|
142
|
+
if (opts.command === 'create') {
|
|
143
|
+
if (!opts.space) failUsage('create requires --space KEY.');
|
|
144
|
+
if (!opts.title) failUsage('create requires --title TITLE.');
|
|
145
|
+
if (!opts.parentId && !opts.allowRoot) failUsage('create requires --parent-id ID unless --allow-root is passed.');
|
|
146
|
+
}
|
|
147
|
+
if (opts.expectedVersion !== null && opts.expectedVersion !== 'auto' && (!Number.isInteger(opts.expectedVersion) || opts.expectedVersion < 1)) failUsage('--expected-version must be "auto" or a positive integer.');
|
|
148
|
+
|
|
149
|
+
async function endpoint(pathname) {
|
|
150
|
+
const res = await fetch(`http://127.0.0.1:${opts.port}${pathname}`);
|
|
151
|
+
if (!res.ok) throw new Error(`DevTools HTTP ${res.status} for ${pathname}`);
|
|
152
|
+
return res.json();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function devtoolsReady() {
|
|
156
|
+
try { await endpoint('/json/version'); return true; } catch { return false; }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function waitDevtools() {
|
|
160
|
+
for (let i = 0; i < 80; i++) {
|
|
161
|
+
if (await devtoolsReady()) return;
|
|
162
|
+
await sleep(250);
|
|
163
|
+
}
|
|
164
|
+
throw new Error('Chrome DevTools endpoint did not start');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function openDevtoolsTab(url) {
|
|
168
|
+
if (!url) return false;
|
|
169
|
+
const endpointUrl = `http://127.0.0.1:${opts.port}/json/new?${encodeURIComponent(url)}`;
|
|
170
|
+
for (const init of [{ method: 'PUT' }, {}]) {
|
|
171
|
+
try {
|
|
172
|
+
const res = await fetch(endpointUrl, init);
|
|
173
|
+
if (res.ok) { await sleep(500); return true; }
|
|
174
|
+
} catch {}
|
|
175
|
+
}
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function hasDevtoolsTabForWiki(url) {
|
|
180
|
+
if (!url) return false;
|
|
181
|
+
const host = new URL(url).host;
|
|
182
|
+
const list = await endpoint('/json/list');
|
|
183
|
+
return list.some(t => t.type === 'page' && t.url && (() => {
|
|
184
|
+
try {
|
|
185
|
+
const tabUrl = new URL(t.url);
|
|
186
|
+
return tabUrl.host === host && tabUrl.pathname.startsWith('/wiki');
|
|
187
|
+
} catch { return false; }
|
|
188
|
+
})());
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function isExecutable(file) {
|
|
192
|
+
try { fs.accessSync(file, fs.constants.X_OK); return true; } catch { return false; }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function resolveBrowserCandidate(candidate) {
|
|
196
|
+
if (!candidate) return null;
|
|
197
|
+
if (candidate.includes(path.sep)) return isExecutable(candidate) ? candidate : null;
|
|
198
|
+
for (const dir of String(process.env.PATH || '').split(path.delimiter)) {
|
|
199
|
+
if (!dir) continue;
|
|
200
|
+
const full = path.join(dir, candidate);
|
|
201
|
+
if (isExecutable(full)) return full;
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function findBrowserExecutable() {
|
|
207
|
+
const candidates = [
|
|
208
|
+
process.env.CHROME,
|
|
209
|
+
process.env.CHROMIUM,
|
|
210
|
+
'google-chrome',
|
|
211
|
+
'google-chrome-stable',
|
|
212
|
+
'chromium',
|
|
213
|
+
'chromium-browser',
|
|
214
|
+
'brave-browser',
|
|
215
|
+
'brave',
|
|
216
|
+
'microsoft-edge',
|
|
217
|
+
'microsoft-edge-stable',
|
|
218
|
+
'vivaldi',
|
|
219
|
+
'vivaldi-stable',
|
|
220
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
221
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
222
|
+
'/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
|
|
223
|
+
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
224
|
+
'/Applications/Vivaldi.app/Contents/MacOS/Vivaldi',
|
|
225
|
+
];
|
|
226
|
+
for (const candidate of candidates) {
|
|
227
|
+
const resolved = resolveBrowserCandidate(candidate);
|
|
228
|
+
if (resolved) return resolved;
|
|
229
|
+
}
|
|
230
|
+
throw new Error('Could not find a Chromium-compatible browser. Install Chrome/Chromium/Brave/Edge/Vivaldi or set CHROME=/path/to/browser.');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function launchChrome(url) {
|
|
234
|
+
const browser = findBrowserExecutable();
|
|
235
|
+
const args = [
|
|
236
|
+
`--remote-debugging-port=${opts.port}`,
|
|
237
|
+
'--remote-debugging-address=127.0.0.1',
|
|
238
|
+
'--remote-allow-origins=*',
|
|
239
|
+
`--user-data-dir=${opts.profileDir}`,
|
|
240
|
+
'--no-first-run',
|
|
241
|
+
'--no-default-browser-check',
|
|
242
|
+
url,
|
|
243
|
+
];
|
|
244
|
+
console.log(`Launching browser: ${browser}`);
|
|
245
|
+
const child = spawn(browser, args, { detached: true, stdio: 'ignore' });
|
|
246
|
+
child.on('error', err => console.error(`Failed to launch browser ${browser}: ${err.message}`));
|
|
247
|
+
child.unref();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function ensureBrowser(openUrl) {
|
|
251
|
+
if (!(await devtoolsReady())) {
|
|
252
|
+
console.log(`Opening Chromium-compatible browser with reusable profile: ${opts.profileDir}`);
|
|
253
|
+
launchChrome(openUrl || wikiBase);
|
|
254
|
+
} else {
|
|
255
|
+
console.log(`Reusing Chrome DevTools on port ${opts.port}`);
|
|
256
|
+
const targetUrl = openUrl || wikiBase;
|
|
257
|
+
const hasTab = await hasDevtoolsTabForWiki(targetUrl);
|
|
258
|
+
if (hasTab) {
|
|
259
|
+
console.log(`Found existing Confluence tab for ${new URL(targetUrl).host}; not opening another tab.`);
|
|
260
|
+
} else {
|
|
261
|
+
const opened = await openDevtoolsTab(targetUrl);
|
|
262
|
+
if (opened) console.log(`Opened target URL in reused browser: ${targetUrl}`);
|
|
263
|
+
else console.warn('Could not open target URL through DevTools; continuing with existing tabs.');
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
await waitDevtools();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function getPageWsUrl() {
|
|
270
|
+
const list = await endpoint('/json/list');
|
|
271
|
+
const pages = list.filter(t => t.type === 'page' && t.webSocketDebuggerUrl);
|
|
272
|
+
const host = new URL(opts.site).host;
|
|
273
|
+
const preferred = pages.find(t => (t.url || '').includes(host)) || pages[0];
|
|
274
|
+
return preferred && preferred.webSocketDebuggerUrl;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function connectCdp(wsUrl) {
|
|
278
|
+
return new Promise((resolve, reject) => {
|
|
279
|
+
const ws = new WebSocket(wsUrl);
|
|
280
|
+
let id = 0;
|
|
281
|
+
const pending = new Map();
|
|
282
|
+
const failTimer = setTimeout(() => reject(new Error('CDP websocket timeout')), 10000);
|
|
283
|
+
|
|
284
|
+
ws.addEventListener('open', () => {
|
|
285
|
+
clearTimeout(failTimer);
|
|
286
|
+
resolve({
|
|
287
|
+
send(method, params = {}) {
|
|
288
|
+
return new Promise((res, rej) => {
|
|
289
|
+
const msgId = ++id;
|
|
290
|
+
pending.set(msgId, { res, rej });
|
|
291
|
+
ws.send(JSON.stringify({ id: msgId, method, params }));
|
|
292
|
+
});
|
|
293
|
+
},
|
|
294
|
+
close() { try { ws.close(); } catch {} },
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
ws.addEventListener('message', ev => {
|
|
299
|
+
let data = ev.data;
|
|
300
|
+
if (typeof data !== 'string') data = Buffer.from(data).toString('utf8');
|
|
301
|
+
const msg = JSON.parse(data);
|
|
302
|
+
if (!msg.id || !pending.has(msg.id)) return;
|
|
303
|
+
const { res, rej } = pending.get(msg.id);
|
|
304
|
+
pending.delete(msg.id);
|
|
305
|
+
if (msg.error) rej(new Error(`${msg.error.message || 'CDP error'} ${JSON.stringify(msg.error)}`));
|
|
306
|
+
else res(msg.result);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
ws.addEventListener('error', err => reject(err));
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function getCookieHeader() {
|
|
314
|
+
const wsUrl = await getPageWsUrl();
|
|
315
|
+
if (!wsUrl) return '';
|
|
316
|
+
const cdp = await connectCdp(wsUrl);
|
|
317
|
+
try {
|
|
318
|
+
await cdp.send('Network.enable');
|
|
319
|
+
const host = new URL(opts.site).host;
|
|
320
|
+
const result = await cdp.send('Network.getCookies', { urls: [`${opts.site}/`, wikiBase] });
|
|
321
|
+
const cookies = (result.cookies || [])
|
|
322
|
+
.filter(c => c.domain && (c.domain === host || c.domain.endsWith(`.${host}`)))
|
|
323
|
+
.map(c => `${c.name}=${c.value}`);
|
|
324
|
+
return cookies.join('; ');
|
|
325
|
+
} finally {
|
|
326
|
+
cdp.close();
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function fetchText(url, cookie, method = 'GET', body = null) {
|
|
331
|
+
const headers = {
|
|
332
|
+
Cookie: cookie,
|
|
333
|
+
Accept: 'application/json',
|
|
334
|
+
'User-Agent': 'confluence-update/1.0',
|
|
335
|
+
};
|
|
336
|
+
if (body !== null) headers['Content-Type'] = 'application/json';
|
|
337
|
+
const res = await fetch(url, { method, redirect: 'follow', headers, body });
|
|
338
|
+
return { status: res.status, contentType: res.headers.get('content-type') || '', text: await res.text() };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function fetchJson(url, cookie, method = 'GET', json = null) {
|
|
342
|
+
const result = await fetchText(url, cookie, method, json === null ? null : JSON.stringify(json));
|
|
343
|
+
let parsed = null;
|
|
344
|
+
try { parsed = JSON.parse(result.text); } catch {}
|
|
345
|
+
return { ...result, json: parsed };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function verifyConfluenceSession(cookie) {
|
|
349
|
+
if (!cookie) return { ok: false, message: 'no Atlassian cookies yet' };
|
|
350
|
+
const probes = [`${wikiBase}/rest/api/user/current`, `${wikiBase}/rest/api/space?limit=1`];
|
|
351
|
+
for (const url of probes) {
|
|
352
|
+
const result = await fetchJson(url, cookie);
|
|
353
|
+
if (result.status === 200 && result.json) return { ok: true, url };
|
|
354
|
+
if (result.status === 401 || result.status === 403) return { ok: false, message: `not authenticated yet (${result.status} from ${url})` };
|
|
355
|
+
if (result.status === 302 || result.status === 303) return { ok: false, message: `still redirected to login (${result.status} from ${url})` };
|
|
356
|
+
if (result.status === 404) continue;
|
|
357
|
+
return { ok: false, message: `session probe HTTP ${result.status} from ${url}` };
|
|
358
|
+
}
|
|
359
|
+
return { ok: false, message: 'could not verify Confluence session' };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function getCookieWithWait(openUrl) {
|
|
363
|
+
await ensureBrowser(openUrl || wikiBase);
|
|
364
|
+
console.log(`If prompted in Chrome, complete SSO for: ${openUrl || wikiBase}`);
|
|
365
|
+
const deadline = Date.now() + opts.waitSec * 1000;
|
|
366
|
+
let last = '';
|
|
367
|
+
while (Date.now() < deadline) {
|
|
368
|
+
try {
|
|
369
|
+
const cookie = await getCookieHeader();
|
|
370
|
+
const session = await verifyConfluenceSession(cookie);
|
|
371
|
+
if (session.ok) {
|
|
372
|
+
if (process.stdout.isTTY) process.stdout.write('\n');
|
|
373
|
+
console.log(`Authenticated Confluence session verified via ${session.url}`);
|
|
374
|
+
return cookie;
|
|
375
|
+
}
|
|
376
|
+
last = session.message;
|
|
377
|
+
} catch (e) { last = e.message; }
|
|
378
|
+
if (process.stdout.isTTY) {
|
|
379
|
+
process.stdout.write(`\r${new Date().toLocaleTimeString()} ${last.padEnd(120).slice(0, 120)}`);
|
|
380
|
+
} else if (Date.now() - deadline + opts.waitSec * 1000 < 4000) {
|
|
381
|
+
console.log(`Waiting up to ${opts.waitSec}s for Confluence session...`);
|
|
382
|
+
}
|
|
383
|
+
await sleep(3000);
|
|
384
|
+
}
|
|
385
|
+
if (process.stdout.isTTY) process.stdout.write('\n');
|
|
386
|
+
throw new Error(`Could not verify authenticated Confluence session. Last result: ${last}`);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function pageUrl(pageId) {
|
|
390
|
+
return `${wikiBase}/spaces/pages/${pageId}`;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function getPage(pageId, cookie) {
|
|
394
|
+
const url = `${wikiBase}/rest/api/content/${pageId}?expand=body.storage,version,space,ancestors`;
|
|
395
|
+
const result = await fetchJson(url, cookie);
|
|
396
|
+
if (result.status !== 200 || !result.json || !result.json.id) {
|
|
397
|
+
throw new Error(`Could not read page ${pageId}. HTTP ${result.status}: ${(result.text || '').slice(0, 300).replace(/\s+/g, ' ')}`);
|
|
398
|
+
}
|
|
399
|
+
return result.json;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function currentStorage(page) {
|
|
403
|
+
return (((page || {}).body || {}).storage || {}).value || '';
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function updatePayload(page, storage) {
|
|
407
|
+
return {
|
|
408
|
+
id: String(page.id),
|
|
409
|
+
type: page.type || 'page',
|
|
410
|
+
title: opts.title || page.title,
|
|
411
|
+
space: { key: page.space && page.space.key },
|
|
412
|
+
body: { storage: { value: storage, representation: 'storage' } },
|
|
413
|
+
version: {
|
|
414
|
+
number: Number(page.version && page.version.number || 0) + 1,
|
|
415
|
+
minorEdit: opts.minorEdit,
|
|
416
|
+
message: opts.message,
|
|
417
|
+
},
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function createPayload(storage) {
|
|
422
|
+
const payload = {
|
|
423
|
+
type: 'page',
|
|
424
|
+
title: opts.title,
|
|
425
|
+
space: { key: opts.space },
|
|
426
|
+
body: { storage: { value: storage, representation: 'storage' } },
|
|
427
|
+
};
|
|
428
|
+
if (opts.parentId) payload.ancestors = [{ id: String(opts.parentId) }];
|
|
429
|
+
return payload;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function makeRunDir(name) {
|
|
433
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
434
|
+
const dir = path.join(opts.rawDir, 'confluence-updates', `${safeName(name)}-${stamp}`);
|
|
435
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
436
|
+
return dir;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function writeAudit(dir, manifest, files) {
|
|
440
|
+
for (const [name, content] of Object.entries(files)) await fsp.writeFile(path.join(dir, name), content);
|
|
441
|
+
await fsp.writeFile(path.join(dir, 'update-run.json'), JSON.stringify(manifest, null, 2));
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function runUpdate(cookie, pageId, inputStorage) {
|
|
445
|
+
const page = await getPage(pageId, cookie);
|
|
446
|
+
const version = Number(page.version && page.version.number || 0);
|
|
447
|
+
if (opts.expectedVersion !== null && opts.expectedVersion !== 'auto' && version !== opts.expectedVersion) {
|
|
448
|
+
throw new Error(`Version mismatch for ${pageId}: expected ${opts.expectedVersion}, current ${version}. Refetch before updating.`);
|
|
449
|
+
}
|
|
450
|
+
let nextStorage = inputStorage;
|
|
451
|
+
if (opts.command === 'replace-block') nextStorage = replaceMarkedBlock(currentStorage(page), opts.marker, inputStorage);
|
|
452
|
+
else if (opts.command === 'replace-text') nextStorage = lib.replaceTextMatch(currentStorage(page), opts.matchText, inputStorage);
|
|
453
|
+
else if (opts.command === 'replace-element') nextStorage = lib.replaceLocalId(currentStorage(page), opts.localId, inputStorage);
|
|
454
|
+
|
|
455
|
+
const payload = updatePayload(page, nextStorage);
|
|
456
|
+
const dir = await makeRunDir(pageId);
|
|
457
|
+
const { generateSimpleDiff } = require('./lib');
|
|
458
|
+
const manifest = {
|
|
459
|
+
command: opts.command,
|
|
460
|
+
dryRun: !opts.apply,
|
|
461
|
+
site: opts.site,
|
|
462
|
+
pageId,
|
|
463
|
+
title: payload.title,
|
|
464
|
+
currentVersion: version,
|
|
465
|
+
nextVersion: payload.version.number,
|
|
466
|
+
representation: opts.representation,
|
|
467
|
+
marker: opts.marker || undefined,
|
|
468
|
+
matchText: opts.matchText || undefined,
|
|
469
|
+
localId: opts.localId || undefined,
|
|
470
|
+
auditDir: dir,
|
|
471
|
+
};
|
|
472
|
+
await writeAudit(dir, manifest, {
|
|
473
|
+
'before.page.json': JSON.stringify(page, null, 2),
|
|
474
|
+
'before.storage.html': currentStorage(page),
|
|
475
|
+
'proposed.storage.html': nextStorage,
|
|
476
|
+
'payload.json': JSON.stringify(payload, null, 2),
|
|
477
|
+
});
|
|
478
|
+
console.log(`${opts.apply ? 'Applying' : 'Dry-run'} ${opts.command} for page ${pageId}: ${page.title} v${version} -> v${payload.version.number}`);
|
|
479
|
+
console.log(`Audit files: ${dir}`);
|
|
480
|
+
|
|
481
|
+
if (!opts.apply) {
|
|
482
|
+
console.log('\n--- Dry-run Diff Summary ---');
|
|
483
|
+
console.log(generateSimpleDiff(currentStorage(page), nextStorage));
|
|
484
|
+
console.log('----------------------------\n');
|
|
485
|
+
console.log('Dry-run only. Re-run with --apply to write to Confluence.');
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
const result = await fetchJson(`${wikiBase}/rest/api/content/${pageId}`, cookie, 'PUT', payload);
|
|
489
|
+
if (result.status !== 200 || !result.json || !result.json.id) {
|
|
490
|
+
throw new Error(`Update failed HTTP ${result.status}: ${(result.text || '').slice(0, 500).replace(/\s+/g, ' ')}`);
|
|
491
|
+
}
|
|
492
|
+
await fsp.writeFile(path.join(dir, 'after.page.json'), JSON.stringify(result.json, null, 2));
|
|
493
|
+
console.log(`Updated page ${pageId} to version ${result.json.version && result.json.version.number || payload.version.number}`);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async function runCreate(cookie, inputStorage) {
|
|
497
|
+
const payload = createPayload(inputStorage);
|
|
498
|
+
const dir = await makeRunDir(`create-${opts.space}-${opts.title}`);
|
|
499
|
+
const manifest = {
|
|
500
|
+
command: 'create',
|
|
501
|
+
dryRun: !opts.apply,
|
|
502
|
+
site: opts.site,
|
|
503
|
+
space: opts.space,
|
|
504
|
+
parentId: opts.parentId || undefined,
|
|
505
|
+
title: opts.title,
|
|
506
|
+
representation: opts.representation,
|
|
507
|
+
auditDir: dir,
|
|
508
|
+
};
|
|
509
|
+
await writeAudit(dir, manifest, {
|
|
510
|
+
'proposed.storage.html': inputStorage,
|
|
511
|
+
'payload.json': JSON.stringify(payload, null, 2),
|
|
512
|
+
});
|
|
513
|
+
console.log(`${opts.apply ? 'Applying' : 'Dry-run'} create page: ${opts.space} / ${opts.title}`);
|
|
514
|
+
console.log(`Audit files: ${dir}`);
|
|
515
|
+
if (!opts.apply) {
|
|
516
|
+
console.log('Dry-run only. Re-run with --apply to write to Confluence.');
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
const result = await fetchJson(`${wikiBase}/rest/api/content`, cookie, 'POST', payload);
|
|
520
|
+
if ((result.status !== 200 && result.status !== 201) || !result.json || !result.json.id) {
|
|
521
|
+
throw new Error(`Create failed HTTP ${result.status}: ${(result.text || '').slice(0, 500).replace(/\s+/g, ' ')}`);
|
|
522
|
+
}
|
|
523
|
+
await fsp.writeFile(path.join(dir, 'after.page.json'), JSON.stringify(result.json, null, 2));
|
|
524
|
+
console.log(`Created page ${result.json.id}: ${result.json.title}`);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function main() {
|
|
528
|
+
const rawInput = await fsp.readFile(path.resolve(opts.file), 'utf8');
|
|
529
|
+
const inputStorage = renderContent(rawInput, opts.representation);
|
|
530
|
+
const pageId = opts.command === 'create' ? '' : extractPageId(opts.pageInput);
|
|
531
|
+
if (opts.command !== 'create' && !pageId) throw new Error(`Could not extract page id from: ${opts.pageInput}`);
|
|
532
|
+
const openUrl = opts.command === 'create' ? wikiBase : pageUrl(pageId);
|
|
533
|
+
const cookie = await getCookieWithWait(openUrl);
|
|
534
|
+
if (opts.command === 'create') await runCreate(cookie, inputStorage);
|
|
535
|
+
else await runUpdate(cookie, pageId, inputStorage);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
main().catch(err => {
|
|
539
|
+
console.error(`\nERROR: ${err.stack || err.message}`);
|
|
540
|
+
process.exit(1);
|
|
541
|
+
});
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function escapeHtml(s) {
|
|
4
|
+
return String(s ?? '')
|
|
5
|
+
.replace(/&/g, '&')
|
|
6
|
+
.replace(/</g, '<')
|
|
7
|
+
.replace(/>/g, '>')
|
|
8
|
+
.replace(/"/g, '"');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function escapeRegExp(s) {
|
|
12
|
+
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function safeName(name) {
|
|
16
|
+
return String(name || 'untitled').replace(/[\\/\0]/g, '_').replace(/^\.+$/, '_').slice(0, 120) || 'untitled';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function extractPageId(input) {
|
|
20
|
+
const s = String(input || '').trim();
|
|
21
|
+
if (/^\d+$/.test(s)) return s;
|
|
22
|
+
try {
|
|
23
|
+
const u = new URL(s);
|
|
24
|
+
const qp = u.searchParams.get('pageId') || u.searchParams.get('pageid') || u.searchParams.get('contentId') || u.searchParams.get('contentid');
|
|
25
|
+
if (qp && /^\d+$/.test(qp)) return qp;
|
|
26
|
+
const patterns = [
|
|
27
|
+
/\/pages\/(\d+)(?:\/|$)/,
|
|
28
|
+
/[?&]pageId=(\d+)/,
|
|
29
|
+
/[?&]contentId=(\d+)/,
|
|
30
|
+
];
|
|
31
|
+
for (const re of patterns) {
|
|
32
|
+
const m = u.href.match(re);
|
|
33
|
+
if (m) return m[1];
|
|
34
|
+
}
|
|
35
|
+
} catch {}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function inlineMarkdown(s) {
|
|
40
|
+
let out = escapeHtml(s);
|
|
41
|
+
out = out.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
42
|
+
out = out.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2">$1</a>');
|
|
43
|
+
out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
44
|
+
out = out.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function markdownToStorage(markdown) {
|
|
49
|
+
const lines = String(markdown ?? '').replace(/\r\n/g, '\n').split('\n');
|
|
50
|
+
const out = [];
|
|
51
|
+
let paragraph = [];
|
|
52
|
+
let list = null;
|
|
53
|
+
let inCode = false;
|
|
54
|
+
let code = [];
|
|
55
|
+
|
|
56
|
+
function flushParagraph() {
|
|
57
|
+
if (!paragraph.length) return;
|
|
58
|
+
out.push(`<p>${inlineMarkdown(paragraph.join(' '))}</p>`);
|
|
59
|
+
paragraph = [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function closeList() {
|
|
63
|
+
if (!list) return;
|
|
64
|
+
out.push(`<${list.type}>${list.items.map(item => `<li>${inlineMarkdown(item)}</li>`).join('')}</${list.type}>`);
|
|
65
|
+
list = null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function flushCode() {
|
|
69
|
+
out.push(`<pre><code>${escapeHtml(code.join('\n'))}</code></pre>`);
|
|
70
|
+
code = [];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const rawLine of lines) {
|
|
74
|
+
const line = rawLine.replace(/\s+$/, '');
|
|
75
|
+
if (/^```/.test(line)) {
|
|
76
|
+
if (inCode) {
|
|
77
|
+
inCode = false;
|
|
78
|
+
flushCode();
|
|
79
|
+
} else {
|
|
80
|
+
flushParagraph();
|
|
81
|
+
closeList();
|
|
82
|
+
inCode = true;
|
|
83
|
+
code = [];
|
|
84
|
+
}
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (inCode) {
|
|
88
|
+
code.push(rawLine);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (!line.trim()) {
|
|
92
|
+
flushParagraph();
|
|
93
|
+
closeList();
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const heading = line.match(/^(#{1,6})\s+(.+)$/);
|
|
97
|
+
if (heading) {
|
|
98
|
+
flushParagraph();
|
|
99
|
+
closeList();
|
|
100
|
+
const level = heading[1].length;
|
|
101
|
+
out.push(`<h${level}>${inlineMarkdown(heading[2].trim())}</h${level}>`);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const bullet = line.match(/^[-*]\s+(.+)$/);
|
|
105
|
+
if (bullet) {
|
|
106
|
+
flushParagraph();
|
|
107
|
+
if (!list || list.type !== 'ul') { closeList(); list = { type: 'ul', items: [] }; }
|
|
108
|
+
list.items.push(bullet[1].trim());
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const ordered = line.match(/^\d+[.)]\s+(.+)$/);
|
|
112
|
+
if (ordered) {
|
|
113
|
+
flushParagraph();
|
|
114
|
+
if (!list || list.type !== 'ol') { closeList(); list = { type: 'ol', items: [] }; }
|
|
115
|
+
list.items.push(ordered[1].trim());
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
closeList();
|
|
119
|
+
paragraph.push(line.trim());
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (inCode) flushCode();
|
|
123
|
+
flushParagraph();
|
|
124
|
+
closeList();
|
|
125
|
+
return out.join('\n');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function renderContent(content, representation) {
|
|
129
|
+
const rep = String(representation || 'storage').toLowerCase();
|
|
130
|
+
if (rep === 'storage') return String(content ?? '');
|
|
131
|
+
if (rep === 'markdown' || rep === 'md') return markdownToStorage(content);
|
|
132
|
+
throw new Error(`Unsupported representation: ${representation}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function blockMarkers(marker) {
|
|
136
|
+
const name = String(marker || '').trim();
|
|
137
|
+
if (!/^[A-Za-z0-9._:-]+$/.test(name)) throw new Error('Marker must contain only letters, digits, dot, underscore, colon, or hyphen');
|
|
138
|
+
return {
|
|
139
|
+
start: `<!-- agent-block:${name}:start -->`,
|
|
140
|
+
end: `<!-- agent-block:${name}:end -->`,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function replaceMarkedBlock(storage, marker, replacementStorage) {
|
|
145
|
+
const { start, end } = blockMarkers(marker);
|
|
146
|
+
const re = new RegExp(`(${escapeRegExp(start)})([\\s\\S]*?)(${escapeRegExp(end)})`);
|
|
147
|
+
if (!re.test(String(storage || ''))) throw new Error(`Marker block not found: ${marker}`);
|
|
148
|
+
return String(storage).replace(re, `$1\n${String(replacementStorage || '')}\n$3`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function replaceTextMatch(storage, matchText, replacementStorage) {
|
|
152
|
+
if (!matchText) throw new Error('Match text cannot be empty');
|
|
153
|
+
const index = storage.indexOf(matchText);
|
|
154
|
+
if (index === -1) throw new Error(`Match text not found: ${matchText.slice(0, 50)}...`);
|
|
155
|
+
if (storage.indexOf(matchText, index + 1) !== -1) throw new Error('Match text is not unique in the page. Please provide a more specific match string.');
|
|
156
|
+
return storage.replace(matchText, replacementStorage);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function replaceLocalId(storage, localId, replacementStorage) {
|
|
160
|
+
if (!localId) throw new Error('local-id cannot be empty');
|
|
161
|
+
const re = new RegExp(`(<[^>]+local-id="${escapeRegExp(localId)}"[^>]*>)([\\s\\S]*?)(</[a-zA-Z0-9]+>)`);
|
|
162
|
+
const match = storage.match(re);
|
|
163
|
+
if (!match) throw new Error(`local-id not found: ${localId}`);
|
|
164
|
+
|
|
165
|
+
// This is a naive replacement that assumes the tag closes cleanly. It works for simple blocks (p, h1, etc.)
|
|
166
|
+
// but might be dangerous for deep nesting unless the user provides the whole replacement tag.
|
|
167
|
+
// Actually, replacing the *entire* matched block is safer and clearer for the user.
|
|
168
|
+
return storage.replace(match[0], replacementStorage);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function generateSimpleDiff(oldText, newText) {
|
|
172
|
+
const oldLines = String(oldText || '').split('\n');
|
|
173
|
+
const newLines = String(newText || '').split('\n');
|
|
174
|
+
const diff = [];
|
|
175
|
+
|
|
176
|
+
// Extremely naive diff just for dry-run summaries without dependencies
|
|
177
|
+
if (oldText === newText) return ' (No changes)';
|
|
178
|
+
|
|
179
|
+
const added = newLines.length - oldLines.length;
|
|
180
|
+
diff.push(` Size changed: ${oldText.length} bytes -> ${newText.length} bytes`);
|
|
181
|
+
diff.push(` Lines changed: ${oldLines.length} -> ${newLines.length} (${added > 0 ? '+' : ''}${added})`);
|
|
182
|
+
|
|
183
|
+
return diff.join('\n');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
module.exports = {
|
|
187
|
+
escapeHtml,
|
|
188
|
+
safeName,
|
|
189
|
+
extractPageId,
|
|
190
|
+
markdownToStorage,
|
|
191
|
+
renderContent,
|
|
192
|
+
blockMarkers,
|
|
193
|
+
replaceMarkedBlock,
|
|
194
|
+
replaceTextMatch,
|
|
195
|
+
replaceLocalId,
|
|
196
|
+
generateSimpleDiff,
|
|
197
|
+
};
|
|
@@ -143,6 +143,15 @@ async function openDevtoolsTab(url) {
|
|
|
143
143
|
return false;
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
async function hasDevtoolsTabForHost(url) {
|
|
147
|
+
if (!url) return false;
|
|
148
|
+
const host = new URL(url).host;
|
|
149
|
+
const list = await endpoint('/json/list');
|
|
150
|
+
return list.some(t => t.type === 'page' && t.url && (() => {
|
|
151
|
+
try { return new URL(t.url).host === host; } catch { return false; }
|
|
152
|
+
})());
|
|
153
|
+
}
|
|
154
|
+
|
|
146
155
|
function isExecutable(file) {
|
|
147
156
|
try { fs.accessSync(file, fs.constants.X_OK); return true; } catch { return false; }
|
|
148
157
|
}
|
|
@@ -322,16 +331,20 @@ async function getCookieWithWait(openUrl) {
|
|
|
322
331
|
const cookie = await getCookieHeader();
|
|
323
332
|
const session = await verifyJiraSession(cookie);
|
|
324
333
|
if (session.ok) {
|
|
325
|
-
process.stdout.write('\n');
|
|
334
|
+
if (process.stdout.isTTY) process.stdout.write('\n');
|
|
326
335
|
console.log(`Authenticated Jira session verified via ${session.url}`);
|
|
327
336
|
return cookie;
|
|
328
337
|
}
|
|
329
338
|
last = session.message;
|
|
330
339
|
} catch (e) { last = e.message; }
|
|
331
|
-
process.stdout.
|
|
340
|
+
if (process.stdout.isTTY) {
|
|
341
|
+
process.stdout.write(`\r${new Date().toLocaleTimeString()} ${last.padEnd(120).slice(0, 120)}`);
|
|
342
|
+
} else if (Date.now() - deadline + opts.waitSec * 1000 < 4000) {
|
|
343
|
+
console.log(`Waiting up to ${opts.waitSec}s for Jira session...`);
|
|
344
|
+
}
|
|
332
345
|
await sleep(3000);
|
|
333
346
|
}
|
|
334
|
-
process.stdout.write('\n');
|
|
347
|
+
if (process.stdout.isTTY) process.stdout.write('\n');
|
|
335
348
|
throw new Error(`Could not verify authenticated Jira session. Last result: ${last}`);
|
|
336
349
|
}
|
|
337
350
|
|
|
@@ -375,10 +388,12 @@ async function fetchBacklogPageWithWait(url, cookie) {
|
|
|
375
388
|
if (result.status === 200 && result.json && Array.isArray(result.json.issues)) return result.json;
|
|
376
389
|
last = `HTTP ${result.status} ${(result.text || '').slice(0, 180).replace(/\s+/g, ' ')}`;
|
|
377
390
|
} catch (e) { last = e.message; }
|
|
378
|
-
process.stdout.
|
|
391
|
+
if (process.stdout.isTTY) {
|
|
392
|
+
process.stdout.write(`\r${new Date().toLocaleTimeString()} waiting for Jira backlog access: ${last.padEnd(120).slice(0, 120)}`);
|
|
393
|
+
}
|
|
379
394
|
await sleep(3000);
|
|
380
395
|
}
|
|
381
|
-
process.stdout.write('\n');
|
|
396
|
+
if (process.stdout.isTTY) process.stdout.write('\n');
|
|
382
397
|
throw new Error(`Could not fetch Jira backlog. Last result: ${last}`);
|
|
383
398
|
}
|
|
384
399
|
|
|
@@ -520,9 +535,14 @@ async function ensureBrowser(browseUrl) {
|
|
|
520
535
|
} else {
|
|
521
536
|
console.log(`Reusing Chrome DevTools on port ${opts.port}`);
|
|
522
537
|
if (browseUrl) {
|
|
523
|
-
const
|
|
524
|
-
if (
|
|
525
|
-
|
|
538
|
+
const hasTab = await hasDevtoolsTabForHost(browseUrl);
|
|
539
|
+
if (hasTab) {
|
|
540
|
+
console.log(`Found existing Jira/Atlassian tab for ${new URL(browseUrl).host}; not opening another tab.`);
|
|
541
|
+
} else {
|
|
542
|
+
const opened = await openDevtoolsTab(browseUrl);
|
|
543
|
+
if (opened) console.log(`Opened target URL in reused browser: ${browseUrl}`);
|
|
544
|
+
else console.warn(`Could not open target URL through DevTools; continuing with existing tabs.`);
|
|
545
|
+
}
|
|
526
546
|
}
|
|
527
547
|
}
|
|
528
548
|
await waitDevtools();
|