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/src/cli.js ADDED
@@ -0,0 +1,496 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { execSync } from 'node:child_process';
6
+ import { input, password, select, confirm, checkbox } from '@inquirer/prompts';
7
+ import { createApiClient } from './api.js';
8
+ import { loadTree, saveTree, unhidePage } from './tree.js';
9
+ import { initialPull, pull, push, detectHidden } from './sync.js';
10
+ import { initRepo, installHook, commitAll } from './git.js';
11
+ import { generateAgentsMd } from './agents-md.js';
12
+
13
+ const projectRoot = process.cwd();
14
+
15
+ function parseArgs() {
16
+ const args = { positional: [] };
17
+ const argv = process.argv.slice(2);
18
+
19
+ for (let i = 0; i < argv.length; i++) {
20
+ if (argv[i] === '--url' && argv[i + 1]) {
21
+ args.url = argv[++i];
22
+ } else if (argv[i] === '--token' && argv[i + 1]) {
23
+ args.token = argv[++i];
24
+ } else if (argv[i] === '--space' && argv[i + 1]) {
25
+ args.space = argv[++i];
26
+ } else if (argv[i] === '--yes' || argv[i] === '-y') {
27
+ args.yes = true;
28
+ } else if (!argv[i].startsWith('-')) {
29
+ args.positional.push(argv[i]);
30
+ }
31
+ }
32
+
33
+ return args;
34
+ }
35
+
36
+ function loadConfig() {
37
+ const configPath = path.join(projectRoot, '.confluence', 'config.json');
38
+ if (!fs.existsSync(configPath)) {
39
+ console.error('Error: .confluence/config.json not found. Run setup first (node cli.js).');
40
+ process.exit(1);
41
+ }
42
+ return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
43
+ }
44
+
45
+ function printBanner() {
46
+ console.log('');
47
+ console.log('╔══════════════════════════════════════╗');
48
+ console.log('║ Confluence Sync — Setup ║');
49
+ console.log('╚══════════════════════════════════════╝');
50
+ console.log('');
51
+ }
52
+
53
+ // --- Command handlers ---
54
+
55
+ async function cmdSpaces() {
56
+ const config = loadConfig();
57
+ const apiClient = createApiClient(config);
58
+ const spaces = await apiClient.getSpaces();
59
+ const result = spaces.map((s) => ({ key: s.key, name: s.name }));
60
+ console.log(JSON.stringify(result, null, 2));
61
+ }
62
+
63
+ async function cmdTree() {
64
+ const config = loadConfig();
65
+ const apiClient = createApiClient(config);
66
+ const pages = await apiClient.getPageTree(config.space);
67
+ console.log(JSON.stringify(pages, null, 2));
68
+ }
69
+
70
+ async function cmdPage(pageId) {
71
+ if (!pageId) {
72
+ console.error('Usage: node cli.js page <pageId>');
73
+ process.exit(1);
74
+ }
75
+ const config = loadConfig();
76
+ const apiClient = createApiClient(config);
77
+ const content = await apiClient.getPageContent(pageId);
78
+ console.log(JSON.stringify(content, null, 2));
79
+ }
80
+
81
+ async function cmdSync(file) {
82
+ const config = loadConfig();
83
+ const apiClient = createApiClient(config);
84
+ const tree = loadTree(projectRoot);
85
+
86
+ // Redirect console.log to stderr so sync progress logs don't pollute JSON stdout
87
+ const origLog = console.log;
88
+ console.log = (...args) => console.error(...args);
89
+
90
+ try {
91
+ let result;
92
+ detectHidden(tree, projectRoot);
93
+ if (file) {
94
+ result = await push(apiClient, tree, projectRoot, config.space, [file]);
95
+ } else {
96
+ result = await pull(apiClient, tree, projectRoot, config.space);
97
+ }
98
+ saveTree(projectRoot, tree);
99
+
100
+ // Restore and output clean JSON to stdout
101
+ console.log = origLog;
102
+ console.log(JSON.stringify(result, null, 2));
103
+ } catch (err) {
104
+ console.log = origLog;
105
+ throw err;
106
+ }
107
+ }
108
+
109
+ async function cmdDelete(pageId) {
110
+ if (!pageId) {
111
+ console.error('Usage: node cli.js delete <pageId>');
112
+ process.exit(1);
113
+ }
114
+ const config = loadConfig();
115
+ const apiClient = createApiClient(config);
116
+ const result = await apiClient.deletePage(pageId);
117
+ console.log(JSON.stringify(result, null, 2));
118
+ }
119
+
120
+ async function cmdLocalTree() {
121
+ const tree = loadTree(projectRoot);
122
+ console.log(JSON.stringify(tree, null, 2));
123
+ }
124
+
125
+ function buildHiddenTree(tree) {
126
+ // Collect all hidden pages
127
+ const hiddenPages = {};
128
+ for (const [pageId, page] of Object.entries(tree.pages)) {
129
+ if (page.hidden) {
130
+ hiddenPages[pageId] = page;
131
+ }
132
+ }
133
+
134
+ if (Object.keys(hiddenPages).length === 0) return [];
135
+
136
+ const hiddenIds = new Set(Object.keys(hiddenPages));
137
+
138
+ // Build children map (only among hidden pages)
139
+ const childrenMap = {}; // parentId -> [pageId, ...]
140
+ const roots = [];
141
+
142
+ for (const pageId of Object.keys(hiddenPages)) {
143
+ const page = hiddenPages[pageId];
144
+ const parentId = page.parentId ? String(page.parentId) : null;
145
+
146
+ // If parent is also hidden, this is a child node; otherwise it's a root
147
+ if (parentId && hiddenIds.has(parentId)) {
148
+ if (!childrenMap[parentId]) childrenMap[parentId] = [];
149
+ childrenMap[parentId].push(pageId);
150
+ } else {
151
+ roots.push(pageId);
152
+ }
153
+ }
154
+
155
+ // Recursively flatten tree into choices with indentation
156
+ const choices = [];
157
+
158
+ function walk(pageId, depth) {
159
+ const page = hiddenPages[pageId];
160
+ const indent = ' '.repeat(depth);
161
+ choices.push({
162
+ name: `${indent}${page.title}`,
163
+ value: pageId,
164
+ });
165
+
166
+ const children = childrenMap[pageId] || [];
167
+ // Sort children alphabetically by title
168
+ children.sort((a, b) => hiddenPages[a].title.localeCompare(hiddenPages[b].title));
169
+ for (const childId of children) {
170
+ walk(childId, depth + 1);
171
+ }
172
+ }
173
+
174
+ // Sort roots alphabetically
175
+ roots.sort((a, b) => hiddenPages[a].title.localeCompare(hiddenPages[b].title));
176
+ for (const rootId of roots) {
177
+ walk(rootId, 0);
178
+ }
179
+
180
+ return choices;
181
+ }
182
+
183
+ function findPagesByTitle(tree, title) {
184
+ // Find all hidden pages where title matches OR any ancestor title matches
185
+ const result = [];
186
+
187
+ for (const [pageId, page] of Object.entries(tree.pages)) {
188
+ if (!page.hidden) continue;
189
+
190
+ // Check if this page's title matches
191
+ if (page.title === title) {
192
+ result.push(pageId);
193
+ continue;
194
+ }
195
+
196
+ // Check if any ancestor has the matching title
197
+ let currentId = page.parentId ? String(page.parentId) : null;
198
+ while (currentId) {
199
+ const ancestor = tree.pages[currentId];
200
+ if (!ancestor) break;
201
+ if (ancestor.title === title) {
202
+ result.push(pageId);
203
+ break;
204
+ }
205
+ currentId = ancestor.parentId ? String(ancestor.parentId) : null;
206
+ }
207
+ }
208
+
209
+ return result;
210
+ }
211
+
212
+ async function cmdRestore(name) {
213
+ const config = loadConfig();
214
+ const apiClient = createApiClient(config);
215
+ const tree = loadTree(projectRoot);
216
+
217
+ // Step 1: sync (pull) to bring tree up to date
218
+ console.log('Синхронизация...');
219
+ detectHidden(tree, projectRoot);
220
+ await pull(apiClient, tree, projectRoot, config.space);
221
+ saveTree(projectRoot, tree);
222
+
223
+ if (name) {
224
+ // --- Command mode: restore by title ---
225
+ const pageIds = findPagesByTitle(tree, name);
226
+
227
+ if (pageIds.length === 0) {
228
+ console.log(`Нет скрытых страниц с названием "${name}".`);
229
+ return;
230
+ }
231
+
232
+ for (const pageId of pageIds) {
233
+ console.log(`Восстановлено: ${tree.pages[pageId].title}`);
234
+ unhidePage(tree, pageId);
235
+ }
236
+
237
+ saveTree(projectRoot, tree);
238
+
239
+ // Re-sync to download restored pages
240
+ console.log('Скачивание восстановленных страниц...');
241
+ await pull(apiClient, tree, projectRoot, config.space);
242
+ saveTree(projectRoot, tree);
243
+
244
+ console.log(`Восстановлено страниц: ${pageIds.length}`);
245
+ } else {
246
+ // --- Interactive mode ---
247
+ const choices = buildHiddenTree(tree);
248
+
249
+ if (choices.length === 0) {
250
+ console.log('Нет скрытых страниц.');
251
+ return;
252
+ }
253
+
254
+ const selected = await checkbox({
255
+ message: 'Выберите страницы для восстановления:',
256
+ choices,
257
+ });
258
+
259
+ if (selected.length === 0) {
260
+ console.log('Ничего не выбрано.');
261
+ return;
262
+ }
263
+
264
+ for (const pageId of selected) {
265
+ console.log(`Восстановлено: ${tree.pages[pageId].title}`);
266
+ unhidePage(tree, pageId);
267
+ }
268
+
269
+ saveTree(projectRoot, tree);
270
+
271
+ // Re-sync to download restored pages
272
+ console.log('Скачивание восстановленных страниц...');
273
+ await pull(apiClient, tree, projectRoot, config.space);
274
+ saveTree(projectRoot, tree);
275
+
276
+ console.log(`Восстановлено страниц: ${selected.length}`);
277
+ }
278
+ }
279
+
280
+ // --- Setup wizard (existing flow) ---
281
+
282
+ async function setupWizard(args) {
283
+ printBanner();
284
+
285
+ // --- Шаг 1-2: URL и Token ---
286
+
287
+ const baseUrl = args.url || await input({
288
+ message: 'Confluence URL:',
289
+ validate: (v) => v.startsWith('http') || 'URL должен начинаться с http/https',
290
+ });
291
+
292
+ const token = args.token || await password({
293
+ message: 'Personal Access Token:',
294
+ mask: '*',
295
+ });
296
+
297
+ // --- Шаг 3: Проверка подключения ---
298
+
299
+ const apiClient = createApiClient({ baseUrl, token });
300
+
301
+ let spaces;
302
+ try {
303
+ console.log('');
304
+ console.log('Проверяю подключение...');
305
+ spaces = await apiClient.getSpaces();
306
+
307
+ if (!spaces || spaces.length === 0) {
308
+ throw new Error('Нет доступных пространств. Проверьте токен и права доступа.');
309
+ }
310
+
311
+ console.log(`Подключено. Доступно пространств: ${spaces.length}`);
312
+ console.log('');
313
+ } catch (err) {
314
+ console.error(`\nОшибка подключения: ${err.message}`);
315
+ process.exit(1);
316
+ }
317
+
318
+ // --- Шаг 4: Выбор space ---
319
+
320
+ let spaceKey;
321
+
322
+ if (args.space) {
323
+ const found = spaces.find((s) => s.key === args.space);
324
+ if (!found) {
325
+ console.error(`Space "${args.space}" не найден. Доступные: ${spaces.map((s) => s.key).join(', ')}`);
326
+ process.exit(1);
327
+ }
328
+ spaceKey = args.space;
329
+ } else {
330
+ spaceKey = await select({
331
+ message: 'Выберите пространство:',
332
+ choices: spaces.map((s) => ({
333
+ name: `${s.key} — ${s.name}`,
334
+ value: s.key,
335
+ })),
336
+ });
337
+ }
338
+
339
+ const spaceName = spaces.find((s) => s.key === spaceKey)?.name || spaceKey;
340
+
341
+ // --- Шаг 5: Подтверждение ---
342
+
343
+ console.log('');
344
+ console.log('Будет создана синхронизация:');
345
+ console.log(` URL: ${baseUrl}`);
346
+ console.log(` Space: ${spaceKey} — ${spaceName}`);
347
+ console.log(` Директория: ./docs/${spaceKey}/`);
348
+ console.log('');
349
+
350
+ if (!args.yes) {
351
+ const ok = await confirm({ message: 'Продолжить?' });
352
+ if (!ok) {
353
+ console.log('Отменено.');
354
+ process.exit(0);
355
+ }
356
+ }
357
+
358
+ // --- Шаг 6: Инициализация ---
359
+
360
+ console.log('');
361
+ console.log('Начинаю синхронизацию...');
362
+ console.log('');
363
+
364
+ // 6.1 Создать директорию docs/{spaceKey}/
365
+ const docsDir = path.join(projectRoot, 'docs', spaceKey);
366
+
367
+ if (!fs.existsSync(docsDir)) {
368
+ fs.mkdirSync(docsDir, { recursive: true });
369
+ }
370
+
371
+ // 6.2 Записать config.json
372
+ const configDir = path.join(projectRoot, '.confluence');
373
+
374
+ if (!fs.existsSync(configDir)) {
375
+ fs.mkdirSync(configDir, { recursive: true });
376
+ }
377
+
378
+ const config = { baseUrl, token, space: spaceKey };
379
+ fs.writeFileSync(path.join(configDir, 'config.json'), JSON.stringify(config, null, 2), 'utf-8');
380
+
381
+ // 6.3 Первичный pull
382
+ const tree = {
383
+ space: spaceKey,
384
+ baseUrl,
385
+ lastSync: null,
386
+ pages: {},
387
+ };
388
+
389
+ let pageCount;
390
+ try {
391
+ pageCount = await initialPull(apiClient, tree, projectRoot, spaceKey);
392
+ } catch (err) {
393
+ console.error(`\nОшибка при загрузке страниц: ${err.message}`);
394
+ process.exit(1);
395
+ }
396
+
397
+ // 6.4 Сохранить tree.json
398
+ saveTree(projectRoot, tree);
399
+
400
+ // 6.5 npm init + install package
401
+ try {
402
+ console.log('Установка зависимостей...');
403
+ execSync('npm init -y', { cwd: projectRoot, stdio: 'ignore' });
404
+ execSync('npm install create-confluence-sync', { cwd: projectRoot, stdio: 'inherit' });
405
+ } catch (err) {
406
+ console.error(`\nОшибка установки пакета: ${err.message}`);
407
+ process.exit(1);
408
+ }
409
+
410
+ // 6.6 git init
411
+ try {
412
+ initRepo(projectRoot);
413
+ } catch (err) {
414
+ console.error(`\nОшибка инициализации git: ${err.message}`);
415
+ process.exit(1);
416
+ }
417
+
418
+ // 6.6 .gitignore
419
+ fs.writeFileSync(
420
+ path.join(projectRoot, '.gitignore'),
421
+ 'node_modules/\n.confluence/config.json\n',
422
+ 'utf-8'
423
+ );
424
+
425
+ // 6.7 Установить hook
426
+ try {
427
+ installHook(projectRoot);
428
+ } catch (err) {
429
+ console.error(`\nОшибка установки git hook: ${err.message}`);
430
+ process.exit(1);
431
+ }
432
+
433
+ // 6.8 Генерация AGENTS.md
434
+ try {
435
+ generateAgentsMd(projectRoot, config, tree);
436
+ } catch (err) {
437
+ console.error(`\nОшибка генерации AGENTS.md: ${err.message}`);
438
+ process.exit(1);
439
+ }
440
+
441
+ // 6.9 Начальный коммит
442
+ try {
443
+ commitAll(projectRoot, 'Initial confluence sync');
444
+ } catch (err) {
445
+ console.error(`\nОшибка начального коммита: ${err.message}`);
446
+ process.exit(1);
447
+ }
448
+
449
+ // --- Шаг 7: Done ---
450
+
451
+ console.log('');
452
+ console.log(`✓ Синхронизация настроена!`);
453
+ console.log(` Директория: ./docs/${spaceKey}/`);
454
+ console.log(` Страниц: ${pageCount}`);
455
+ console.log('');
456
+ console.log(' Теперь можно редактировать файлы и коммитить — синхронизация автоматическая.');
457
+ console.log('');
458
+ }
459
+
460
+ // --- Main ---
461
+
462
+ const COMMANDS = {
463
+ spaces: cmdSpaces,
464
+ tree: cmdTree,
465
+ page: cmdPage,
466
+ sync: cmdSync,
467
+ delete: cmdDelete,
468
+ 'local-tree': cmdLocalTree,
469
+ restore: cmdRestore,
470
+ };
471
+
472
+ async function run() {
473
+ const args = parseArgs();
474
+ const command = args.positional[0];
475
+
476
+ if (command && COMMANDS[command]) {
477
+ const handler = COMMANDS[command];
478
+ const cmdArgs = args.positional.slice(1);
479
+
480
+ // Commands that take an argument pass it; others get no args
481
+ if (command === 'page' || command === 'delete') {
482
+ await handler(cmdArgs[0]);
483
+ } else if (command === 'sync' || command === 'restore') {
484
+ await handler(cmdArgs[0]); // undefined if no arg = default mode
485
+ } else {
486
+ await handler();
487
+ }
488
+ } else {
489
+ await setupWizard(args);
490
+ }
491
+ }
492
+
493
+ run().catch((err) => {
494
+ console.error(`\nНеожиданная ошибка: ${err.message}`);
495
+ process.exit(1);
496
+ });
package/src/git.js ADDED
@@ -0,0 +1,137 @@
1
+ import { execSync } from 'child_process';
2
+ import { writeFileSync, chmodSync } from 'fs';
3
+ import { join } from 'path';
4
+
5
+ function exec(projectRoot, command) {
6
+ try {
7
+ return execSync(command, { cwd: projectRoot, encoding: 'utf-8' }).trim();
8
+ } catch (err) {
9
+ const stderr = err.stderr ? err.stderr.toString().trim() : err.message;
10
+ throw new Error(`Git command failed: ${command}\n${stderr}`);
11
+ }
12
+ }
13
+
14
+ function getChangedFiles(projectRoot) {
15
+ const output = exec(projectRoot, 'git diff --name-only HEAD~1 HEAD');
16
+ if (!output) return [];
17
+ return output.split('\n').filter(f => f.endsWith('.xhtml'));
18
+ }
19
+
20
+ function getDeletedFiles(projectRoot) {
21
+ const output = exec(projectRoot, 'git diff --name-only --diff-filter=D HEAD~1 HEAD');
22
+ if (!output) return [];
23
+ return output.split('\n').filter(f => f.endsWith('.xhtml'));
24
+ }
25
+
26
+ function getChangedFilesUncommitted(projectRoot) {
27
+ const output = exec(projectRoot, 'git diff --name-only HEAD');
28
+ if (!output) return [];
29
+ return output.split('\n').filter(f => f.endsWith('.xhtml'));
30
+ }
31
+
32
+ function createBranch(projectRoot, name) {
33
+ try {
34
+ exec(projectRoot, `git checkout -b ${name}`);
35
+ } catch {
36
+ exec(projectRoot, `git checkout ${name}`);
37
+ }
38
+ }
39
+
40
+ function switchBranch(projectRoot, name) {
41
+ exec(projectRoot, `git checkout ${name}`);
42
+ }
43
+
44
+ function getCurrentBranch(projectRoot) {
45
+ return exec(projectRoot, 'git rev-parse --abbrev-ref HEAD');
46
+ }
47
+
48
+ function commitAll(projectRoot, message) {
49
+ exec(projectRoot, 'git add -A');
50
+
51
+ const status = exec(projectRoot, 'git status --porcelain');
52
+ if (!status) return false;
53
+
54
+ const escaped = message.replace(/"/g, '\\"');
55
+ try {
56
+ execSync(`git commit -m "${escaped}"`, {
57
+ cwd: projectRoot,
58
+ encoding: 'utf-8',
59
+ env: { ...process.env, CONFLUENCE_SYNC_RUNNING: '1' },
60
+ });
61
+ } catch (err) {
62
+ const stderr = err.stderr ? err.stderr.toString().trim() : err.message;
63
+ throw new Error(`Git commit failed: ${stderr}`);
64
+ }
65
+
66
+ return true;
67
+ }
68
+
69
+ function mergeBranch(projectRoot, source) {
70
+ try {
71
+ exec(projectRoot, `git merge ${source}`);
72
+ return true;
73
+ } catch (err) {
74
+ if (err.message.includes('CONFLICT') || err.message.includes('conflict')) {
75
+ throw new Error(
76
+ `Merge conflict while merging "${source}". Resolve conflicts manually and commit the result.`
77
+ );
78
+ }
79
+ throw err;
80
+ }
81
+ }
82
+
83
+ function deleteBranch(projectRoot, name) {
84
+ exec(projectRoot, `git branch -D ${name}`);
85
+ }
86
+
87
+ function stageFiles(projectRoot, files) {
88
+ if (!files || files.length === 0) return;
89
+ const paths = files.map(f => `"${f}"`).join(' ');
90
+ exec(projectRoot, `git add ${paths}`);
91
+ }
92
+
93
+ function installHook(projectRoot) {
94
+ const hookContent = `#!/bin/sh
95
+ if [ "$CONFLUENCE_SYNC_RUNNING" = "1" ]; then
96
+ exit 0
97
+ fi
98
+ export CONFLUENCE_SYNC_RUNNING=1
99
+ node "$(dirname "$0")/../../node_modules/create-confluence-sync/src/hook.js" "$(git rev-parse --show-toplevel)"
100
+ `;
101
+
102
+ const hookPath = join(projectRoot, '.git', 'hooks', 'post-commit');
103
+ writeFileSync(hookPath, hookContent, 'utf-8');
104
+
105
+ try {
106
+ chmodSync(hookPath, 0o755);
107
+ } catch {
108
+ // chmod may not work on Windows, hook file is still written
109
+ }
110
+ }
111
+
112
+ function hasChanges(projectRoot) {
113
+ const output = exec(projectRoot, 'git status --porcelain');
114
+ return output.length > 0;
115
+ }
116
+
117
+ function initRepo(projectRoot) {
118
+ exec(projectRoot, 'git init');
119
+ exec(projectRoot, 'git config core.longpaths true');
120
+ return true;
121
+ }
122
+
123
+ export {
124
+ getChangedFiles,
125
+ getDeletedFiles,
126
+ getChangedFilesUncommitted,
127
+ createBranch,
128
+ switchBranch,
129
+ getCurrentBranch,
130
+ commitAll,
131
+ mergeBranch,
132
+ deleteBranch,
133
+ stageFiles,
134
+ installHook,
135
+ hasChanges,
136
+ initRepo,
137
+ };