create-confluence-sync 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # create-confluence-sync
2
+
3
+ Bidirectional documentation sync between local files and Confluence Server via Git.
4
+
5
+ Edit XHTML files locally, commit — changes sync to Confluence automatically. Someone edits in Confluence — changes sync back on your next commit.
6
+
7
+ ## Quick Start
8
+
9
+ ```bash
10
+ npx create-confluence-sync
11
+ ```
12
+
13
+ The wizard will ask for:
14
+ - Confluence URL
15
+ - Personal Access Token
16
+ - Space to sync
17
+
18
+ It downloads all pages, sets up Git with auto-sync hooks, and you're ready.
19
+
20
+ ## How It Works
21
+
22
+ 1. You edit `.xhtml` files in `docs/` folder
23
+ 2. You `git commit`
24
+ 3. Post-commit hook automatically:
25
+ - Pulls changes from Confluence
26
+ - Merges with your changes
27
+ - Pushes your changes to Confluence
28
+ - Updates the local tree map
29
+
30
+ ## File Structure
31
+
32
+ After setup:
33
+ ```
34
+ your-project/
35
+ ├── docs/
36
+ │ └── LLS/ # your Confluence space
37
+ │ ├── Page Title/
38
+ │ │ ├── Page Title.xhtml
39
+ │ │ └── Child Page/
40
+ │ │ └── Child Page.xhtml
41
+ ├── .confluence/
42
+ │ ├── config.json # connection settings (gitignored)
43
+ │ └── tree.json # page map (tracked in git)
44
+ ├── AGENTS.md # instructions for AI agents
45
+ └── .gitignore
46
+ ```
47
+
48
+ ## Commands
49
+
50
+ ```bash
51
+ # Setup (first time)
52
+ npx create-confluence-sync
53
+
54
+ # Manual sync
55
+ npx create-confluence-sync sync
56
+
57
+ # Push specific file
58
+ npx create-confluence-sync sync "docs/LLS/Page/Page.xhtml"
59
+
60
+ # Restore hidden pages (interactive)
61
+ npx create-confluence-sync restore
62
+
63
+ # Restore by name
64
+ npx create-confluence-sync restore "Page Title"
65
+
66
+ # List spaces
67
+ npx create-confluence-sync spaces
68
+
69
+ # Get page content
70
+ npx create-confluence-sync page <pageId>
71
+
72
+ # View remote tree
73
+ npx create-confluence-sync tree
74
+
75
+ # View local tree
76
+ npx create-confluence-sync local-tree
77
+
78
+ # Delete page from Confluence
79
+ npx create-confluence-sync delete <pageId>
80
+ ```
81
+
82
+ ## Hiding Pages (Virtual Delete)
83
+
84
+ Delete a file or folder locally and commit. The page stays in Confluence but stops syncing locally. Use `restore` to bring it back.
85
+
86
+ ## Format
87
+
88
+ Files use Confluence Storage Format (XHTML):
89
+ ```html
90
+ <h2>Title</h2>
91
+ <p>Text with <strong>bold</strong> and <em>italic</em>.</p>
92
+ <ul>
93
+ <li>Item 1</li>
94
+ <li>Item 2</li>
95
+ </ul>
96
+ ```
97
+
98
+ ## AI Agent Compatible
99
+
100
+ The generated `AGENTS.md` file contains full instructions for AI agents (Claude, GPT, etc.) to create, edit, and manage documentation through the same Git workflow.
101
+
102
+ ## Requirements
103
+
104
+ - Node.js 20+
105
+ - Git
106
+ - Confluence Server with REST API access
107
+ - Personal Access Token
108
+
109
+ ## License
110
+
111
+ MIT
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "create-confluence-sync",
3
+ "version": "1.0.0",
4
+ "description": "Bidirectional Confluence Server documentation sync via Git. Edit XHTML locally, commit, auto-sync with Confluence.",
5
+ "type": "module",
6
+ "bin": {
7
+ "create-confluence-sync": "./src/cli.js"
8
+ },
9
+ "main": "./src/cli.js",
10
+ "scripts": {
11
+ "start": "node src/cli.js",
12
+ "test": "node --test tests/e2e.test.js"
13
+ },
14
+ "keywords": [
15
+ "confluence",
16
+ "sync",
17
+ "documentation",
18
+ "git"
19
+ ],
20
+ "author": "damix96",
21
+ "license": "MIT",
22
+ "dependencies": {
23
+ "@inquirer/prompts": "^7.10.1"
24
+ }
25
+ }
@@ -0,0 +1,273 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * Build a tree structure from flat pages map.
6
+ * Returns array of root nodes, each with nested `children`.
7
+ */
8
+ function buildPageTree(pages) {
9
+ const nodes = {};
10
+ const roots = [];
11
+
12
+ for (const [id, page] of Object.entries(pages)) {
13
+ nodes[id] = { id, ...page, children: [] };
14
+ }
15
+
16
+ for (const [id, node] of Object.entries(nodes)) {
17
+ if (node.parentId && nodes[node.parentId]) {
18
+ nodes[node.parentId].children.push(node);
19
+ } else {
20
+ roots.push(node);
21
+ }
22
+ }
23
+
24
+ // Sort children alphabetically by title at each level
25
+ const sortChildren = (nodeList) => {
26
+ nodeList.sort((a, b) => a.title.localeCompare(b.title));
27
+ for (const node of nodeList) {
28
+ sortChildren(node.children);
29
+ }
30
+ };
31
+ sortChildren(roots);
32
+
33
+ return roots;
34
+ }
35
+
36
+ /**
37
+ * Render a tree of pages as an indented directory listing.
38
+ * Only visible (non-hidden) pages are shown.
39
+ */
40
+ function renderTree(roots, space, indent = '') {
41
+ const lines = [];
42
+
43
+ for (let i = 0; i < roots.length; i++) {
44
+ const node = roots[i];
45
+ if (node.hidden) continue;
46
+
47
+ const isLast = i === roots.length - 1;
48
+ const connector = isLast ? '\u2514\u2500\u2500 ' : '\u251c\u2500\u2500 ';
49
+ const childIndent = indent + (isLast ? ' ' : '\u2502 ');
50
+
51
+ lines.push(`${indent}${connector}${node.title}/`);
52
+ lines.push(`${childIndent}\u251c\u2500\u2500 ${node.title}.xhtml`);
53
+
54
+ const visibleChildren = node.children.filter((c) => !c.hidden);
55
+ const childLines = renderTree(visibleChildren, space, childIndent);
56
+ if (childLines.length > 0) {
57
+ lines.push(...childLines);
58
+ }
59
+ }
60
+
61
+ return lines;
62
+ }
63
+
64
+ /**
65
+ * Generate the full AGENTS.md content.
66
+ */
67
+ function buildContent(config, tree) {
68
+ const space = tree.space || config.space || 'SPACE';
69
+ const baseUrl = tree.baseUrl || config.baseUrl || config.url || '';
70
+ const lastSync = tree.lastSync || 'never';
71
+
72
+ const roots = buildPageTree(tree.pages || {});
73
+ const visibleRoots = roots.filter((r) => !r.hidden);
74
+ const treeLines = renderTree(visibleRoots, space);
75
+ const treeBlock =
76
+ treeLines.length > 0
77
+ ? `docs/${space}/\n${treeLines.join('\n')}`
78
+ : `docs/${space}/ (empty)`;
79
+
80
+ return `# AGENTS.md
81
+
82
+ > Auto-generated by confluence-sync. Do not edit manually.
83
+ > Re-generated on every sync.
84
+
85
+ ## What is this project
86
+
87
+ This repository is a **bidirectional documentation sync** between a local file system and **Confluence**.
88
+ All interaction happens through files and git commits — no direct Confluence API calls needed.
89
+
90
+ | Parameter | Value |
91
+ |-----------|-------|
92
+ | Confluence URL | ${baseUrl} |
93
+ | Space | ${space} |
94
+ | Last synced | ${lastSync} |
95
+
96
+ ---
97
+
98
+ ## File structure
99
+
100
+ \`\`\`
101
+ project-root/
102
+ \u251c\u2500\u2500 docs/${space}/ <- documentation mirror of Confluence space
103
+ \u251c\u2500\u2500 .confluence/ <- internal data, DO NOT TOUCH
104
+ \u2502 \u251c\u2500\u2500 config.json <- connection config (in .gitignore)
105
+ \u2502 \u2514\u2500\u2500 tree.json <- structure map (page IDs, versions, hidden flags)
106
+ \u251c\u2500\u2500 AGENTS.md <- this file
107
+ \u2514\u2500\u2500 .gitignore
108
+ \`\`\`
109
+
110
+ ### docs/${space}/
111
+
112
+ This folder mirrors the Confluence space hierarchy:
113
+
114
+ - **Folder** = Confluence page
115
+ - **Nesting** = parent-child relationship
116
+ - **File \`{title}.xhtml\`** inside the folder = page content
117
+
118
+ ### .confluence/
119
+
120
+ Internal sync data. **Never modify these files manually.**
121
+
122
+ - \`tree.json\` — map of all pages: their IDs, versions, paths, hidden flags
123
+ - \`config.json\` — connection credentials (excluded from git)
124
+
125
+ ---
126
+
127
+ ## File format
128
+
129
+ Pages are stored in **Confluence Storage Format** (XHTML with Confluence-specific tags).
130
+
131
+ ### Basic markup
132
+
133
+ \`\`\`xhtml
134
+ <h2>Heading</h2>
135
+ <p>Text with <strong>bold</strong> and <em>italic</em>.</p>
136
+
137
+ <ul>
138
+ <li>Item 1</li>
139
+ <li>Item 2</li>
140
+ </ul>
141
+
142
+ <ol>
143
+ <li>First</li>
144
+ <li>Second</li>
145
+ </ol>
146
+ \`\`\`
147
+
148
+ ### Tables
149
+
150
+ \`\`\`xhtml
151
+ <table>
152
+ <tbody>
153
+ <tr>
154
+ <th>Column A</th>
155
+ <th>Column B</th>
156
+ </tr>
157
+ <tr>
158
+ <td>Value 1</td>
159
+ <td>Value 2</td>
160
+ </tr>
161
+ </tbody>
162
+ </table>
163
+ \`\`\`
164
+
165
+ ### Code blocks (Confluence macro)
166
+
167
+ \`\`\`xhtml
168
+ <ac:structured-macro ac:name="code">
169
+ <ac:parameter ac:name="language">java</ac:parameter>
170
+ <ac:plain-text-body><![CDATA[public class Example {
171
+ public static void main(String[] args) {
172
+ System.out.println("Hello");
173
+ }
174
+ }]]></ac:plain-text-body>
175
+ </ac:structured-macro>
176
+ \`\`\`
177
+
178
+ ### Info/warning panels
179
+
180
+ \`\`\`xhtml
181
+ <ac:structured-macro ac:name="info">
182
+ <ac:rich-text-body>
183
+ <p>This is an informational note.</p>
184
+ </ac:rich-text-body>
185
+ </ac:structured-macro>
186
+ \`\`\`
187
+
188
+ ---
189
+
190
+ ## Rules
191
+
192
+ ### MANDATORY
193
+
194
+ - Work **only through files and git** — edit \`.xhtml\` files, then \`git commit\`
195
+ - Create/edit \`.xhtml\` files in the correct folder under \`docs/${space}/\`
196
+ - Commit your changes — synchronization is automatic (post-commit hook)
197
+ - Follow valid XHTML format (Confluence Storage Format)
198
+
199
+ ### FORBIDDEN
200
+
201
+ - **DO NOT** touch the \`.confluence/\` folder or any files inside it
202
+ - **DO NOT** call the Confluence API directly
203
+ - **DO NOT** bypass the sync flow
204
+ - **DO NOT** modify git hooks (\`.git/hooks/\`)
205
+ - **DO NOT** commit \`.confluence/config.json\` (it contains credentials)
206
+
207
+ ---
208
+
209
+ ## How to create a new page
210
+
211
+ 1. Identify the parent folder where the page should appear in the Confluence hierarchy
212
+ 2. Create a folder with the page title: \`docs/${space}/{parent}/New Page/\`
213
+ 3. Create a file with the same name: \`docs/${space}/{parent}/New Page/New Page.xhtml\`
214
+ 4. Write content in XHTML format (see examples above)
215
+ 5. Stage and commit:
216
+ \`\`\`
217
+ git add "docs/${space}/{parent}/New Page/"
218
+ git commit -m "Add page: New Page"
219
+ \`\`\`
220
+ 6. The page will be automatically created in Confluence
221
+
222
+ ## How to edit a page
223
+
224
+ 1. Find the \`.xhtml\` file for the page you want to edit
225
+ 2. Modify the content (keep valid XHTML)
226
+ 3. Commit:
227
+ \`\`\`
228
+ git add "docs/${space}/path/to/Page.xhtml"
229
+ git commit -m "Update page: Page"
230
+ \`\`\`
231
+ 4. Changes will be automatically pushed to Confluence
232
+
233
+ ## How to hide (virtually delete) a page
234
+
235
+ Hiding removes the page from your local tree without deleting it from Confluence.
236
+
237
+ 1. Delete the folder/file from the file system
238
+ 2. Commit:
239
+ \`\`\`
240
+ git add -A
241
+ git commit -m "Hide page: Page Name"
242
+ \`\`\`
243
+ 3. The file disappears locally, but the Confluence page remains intact
244
+ 4. The page will not be pulled back on future syncs
245
+ 5. To restore: set \`hidden: false\` for the page in \`.confluence/tree.json\` — this is the **only** allowed manual edit of \`.confluence/\` files — the page will reappear on next sync
246
+
247
+ ---
248
+
249
+ ## Current page tree
250
+
251
+ \`\`\`
252
+ ${treeBlock}
253
+ \`\`\`
254
+
255
+ ---
256
+
257
+ *Generated by confluence-sync*
258
+ `;
259
+ }
260
+
261
+ /**
262
+ * Generate and write AGENTS.md to the project root.
263
+ *
264
+ * @param {string} projectRoot - absolute path to project root
265
+ * @param {object} config - parsed config (baseUrl/url, space, token)
266
+ * @param {object} tree - parsed tree.json (space, pages, lastSync, etc.)
267
+ */
268
+ export function generateAgentsMd(projectRoot, config, tree) {
269
+ const content = buildContent(config, tree);
270
+ const filePath = path.join(projectRoot, 'AGENTS.md');
271
+ fs.writeFileSync(filePath, content, 'utf-8');
272
+ return filePath;
273
+ }
package/src/api.js ADDED
@@ -0,0 +1,207 @@
1
+ import https from 'node:https';
2
+ import http from 'node:http';
3
+
4
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
5
+
6
+ function request(baseUrl, token, method, path, body = null) {
7
+ return new Promise((resolve, reject) => {
8
+ const url = new URL(path, baseUrl);
9
+ const transport = url.protocol === 'https:' ? https : http;
10
+
11
+ const options = {
12
+ method,
13
+ hostname: url.hostname,
14
+ port: url.port || (url.protocol === 'https:' ? 443 : 80),
15
+ path: url.pathname + url.search,
16
+ headers: {
17
+ 'Authorization': `Bearer ${token}`,
18
+ 'Accept': 'application/json',
19
+ },
20
+ };
21
+
22
+ const payload = body !== null ? JSON.stringify(body) : null;
23
+
24
+ if (payload !== null) {
25
+ options.headers['Content-Type'] = 'application/json; charset=utf-8';
26
+ options.headers['Content-Length'] = Buffer.byteLength(payload);
27
+ }
28
+
29
+ const req = transport.request(options, (res) => {
30
+ const chunks = [];
31
+ res.on('data', (chunk) => chunks.push(chunk));
32
+ res.on('end', () => {
33
+ const buffer = Buffer.concat(chunks);
34
+
35
+ if (res.statusCode >= 400) {
36
+ const text = buffer.toString('utf-8');
37
+ let message;
38
+ try {
39
+ const json = JSON.parse(text);
40
+ message = json.message || json.errorMessage || text;
41
+ } catch {
42
+ message = text;
43
+ }
44
+ reject(new Error(`Confluence API error ${res.statusCode}: ${message}`));
45
+ return;
46
+ }
47
+
48
+ resolve({ statusCode: res.statusCode, buffer, headers: res.headers });
49
+ });
50
+ });
51
+
52
+ req.on('error', reject);
53
+
54
+ if (payload !== null) {
55
+ req.write(payload);
56
+ }
57
+
58
+ req.end();
59
+ });
60
+ }
61
+
62
+ async function jsonRequest(baseUrl, token, method, path, body = null) {
63
+ const { buffer } = await request(baseUrl, token, method, path, body);
64
+ const text = buffer.toString('utf-8');
65
+ if (!text) return null;
66
+ return JSON.parse(text);
67
+ }
68
+
69
+ export function createApiClient(config) {
70
+ const { baseUrl, token } = config;
71
+
72
+ async function getSpaces() {
73
+ const data = await jsonRequest(baseUrl, token, 'GET', '/rest/api/space');
74
+ return data.results;
75
+ }
76
+
77
+ async function getPageTree(spaceKey) {
78
+ const pages = {};
79
+ let start = 0;
80
+ const limit = 500;
81
+
82
+ while (true) {
83
+ const data = await jsonRequest(
84
+ baseUrl, token, 'GET',
85
+ `/rest/api/content?spaceKey=${encodeURIComponent(spaceKey)}&expand=ancestors,version&limit=${limit}&start=${start}`
86
+ );
87
+
88
+ if (!data.results || data.results.length === 0) break;
89
+
90
+ for (const page of data.results) {
91
+ const ancestors = page.ancestors || [];
92
+ const parentId = ancestors.length > 0
93
+ ? String(ancestors[ancestors.length - 1].id)
94
+ : null;
95
+
96
+ pages[String(page.id)] = {
97
+ title: page.title,
98
+ parentId,
99
+ version: page.version.number,
100
+ };
101
+ }
102
+
103
+ if (data.size + data.start >= data.totalSize) break;
104
+ start += data.results.length;
105
+ }
106
+
107
+ return pages;
108
+ }
109
+
110
+ async function getPageContent(pageId) {
111
+ const data = await jsonRequest(
112
+ baseUrl, token, 'GET',
113
+ `/rest/api/content/${pageId}?expand=body.storage,version`
114
+ );
115
+
116
+ return {
117
+ title: data.title,
118
+ body: data.body?.storage?.value || '',
119
+ version: data.version?.number || 0,
120
+ };
121
+ }
122
+
123
+ async function getPageAttachments(pageId) {
124
+ const data = await jsonRequest(
125
+ baseUrl, token, 'GET',
126
+ `/rest/api/content/${pageId}/child/attachment`
127
+ );
128
+
129
+ return (data.results || []).map((att) => ({
130
+ id: String(att.id),
131
+ title: att.title,
132
+ version: att.version?.number || 0,
133
+ downloadPath: att._links?.download || null,
134
+ }));
135
+ }
136
+
137
+ async function createPage(spaceKey, parentId, title, body) {
138
+ const payload = {
139
+ type: 'page',
140
+ title,
141
+ space: { key: spaceKey },
142
+ body: {
143
+ storage: {
144
+ value: body,
145
+ representation: 'storage',
146
+ },
147
+ },
148
+ };
149
+
150
+ if (parentId) {
151
+ payload.ancestors = [{ id: String(parentId) }];
152
+ }
153
+
154
+ const data = await jsonRequest(baseUrl, token, 'POST', '/rest/api/content', payload);
155
+
156
+ return {
157
+ id: String(data.id),
158
+ version: data.version.number,
159
+ };
160
+ }
161
+
162
+ async function updatePage(pageId, title, body, currentVersion) {
163
+ const payload = {
164
+ type: 'page',
165
+ title,
166
+ version: { number: currentVersion + 1 },
167
+ body: {
168
+ storage: {
169
+ value: body,
170
+ representation: 'storage',
171
+ },
172
+ },
173
+ };
174
+
175
+ const data = await jsonRequest(
176
+ baseUrl, token, 'PUT',
177
+ `/rest/api/content/${pageId}`,
178
+ payload
179
+ );
180
+
181
+ return {
182
+ id: String(data.id),
183
+ version: data.version.number,
184
+ };
185
+ }
186
+
187
+ async function downloadAttachment(downloadPath) {
188
+ const { buffer } = await request(baseUrl, token, 'GET', downloadPath);
189
+ return buffer;
190
+ }
191
+
192
+ async function deletePage(pageId) {
193
+ await request(baseUrl, token, 'DELETE', `/rest/api/content/${pageId}`);
194
+ return { deleted: true, pageId: String(pageId) };
195
+ }
196
+
197
+ return {
198
+ getSpaces,
199
+ getPageTree,
200
+ getPageContent,
201
+ getPageAttachments,
202
+ createPage,
203
+ updatePage,
204
+ deletePage,
205
+ downloadAttachment,
206
+ };
207
+ }