@aholbreich/agent-skills 0.6.1 → 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 CHANGED
@@ -1,5 +1,11 @@
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
+
3
9
  ## 0.6.1 - 2026-05-07
4
10
 
5
11
  Fixed:
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 fetchers 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 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 fetchers:
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.6.1",
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",
@@ -363,16 +363,20 @@ async function getCookieWithWait(openUrl) {
363
363
  const cookie = await getCookieHeader();
364
364
  const session = await verifyConfluenceSession(cookie);
365
365
  if (session.ok) {
366
- process.stdout.write('\n');
366
+ if (process.stdout.isTTY) process.stdout.write('\n');
367
367
  console.log(`Authenticated Confluence session verified via ${session.url}`);
368
368
  return cookie;
369
369
  }
370
370
  last = session.message;
371
371
  } catch (e) { last = e.message; }
372
- process.stdout.write(`\r${new Date().toLocaleTimeString()} ${last.padEnd(120).slice(0, 120)}`);
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
+ }
373
377
  await sleep(3000);
374
378
  }
375
- process.stdout.write('\n');
379
+ if (process.stdout.isTTY) process.stdout.write('\n');
376
380
  throw new Error(`Could not verify authenticated Confluence session. Last result: ${last}`);
377
381
  }
378
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, '&amp;')
6
+ .replace(/</g, '&lt;')
7
+ .replace(/>/g, '&gt;')
8
+ .replace(/"/g, '&quot;');
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
+ };
@@ -331,16 +331,20 @@ async function getCookieWithWait(openUrl) {
331
331
  const cookie = await getCookieHeader();
332
332
  const session = await verifyJiraSession(cookie);
333
333
  if (session.ok) {
334
- process.stdout.write('\n');
334
+ if (process.stdout.isTTY) process.stdout.write('\n');
335
335
  console.log(`Authenticated Jira session verified via ${session.url}`);
336
336
  return cookie;
337
337
  }
338
338
  last = session.message;
339
339
  } catch (e) { last = e.message; }
340
- process.stdout.write(`\r${new Date().toLocaleTimeString()} ${last.padEnd(120).slice(0, 120)}`);
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
+ }
341
345
  await sleep(3000);
342
346
  }
343
- process.stdout.write('\n');
347
+ if (process.stdout.isTTY) process.stdout.write('\n');
344
348
  throw new Error(`Could not verify authenticated Jira session. Last result: ${last}`);
345
349
  }
346
350
 
@@ -384,10 +388,12 @@ async function fetchBacklogPageWithWait(url, cookie) {
384
388
  if (result.status === 200 && result.json && Array.isArray(result.json.issues)) return result.json;
385
389
  last = `HTTP ${result.status} ${(result.text || '').slice(0, 180).replace(/\s+/g, ' ')}`;
386
390
  } catch (e) { last = e.message; }
387
- process.stdout.write(`\r${new Date().toLocaleTimeString()} waiting for Jira backlog access: ${last.padEnd(120).slice(0, 120)}`);
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
+ }
388
394
  await sleep(3000);
389
395
  }
390
- process.stdout.write('\n');
396
+ if (process.stdout.isTTY) process.stdout.write('\n');
391
397
  throw new Error(`Could not fetch Jira backlog. Last result: ${last}`);
392
398
  }
393
399