@wonseok-han/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/dist/sync.js ADDED
@@ -0,0 +1,288 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * confluence-sync — Markdown 디렉토리 → Confluence Cloud 단방향 동기화 (진입점)
4
+ *
5
+ * confluence-sync [경로...] base 전체(또는 지정 파일/폴더)에서 변경된 문서만 갱신(+신규)
6
+ * confluence-sync --base <dir> 동기화 루트 지정(필수, 또는 env CONFLUENCE_SYNC_BASE)
7
+ * confluence-sync --dry-run 호출 없이 대상·상태 출력
8
+ * confluence-sync --list 인식된 문서·제목·계층만 출력(인증 불필요)
9
+ * confluence-sync --rebuild 매핑된 페이지 전부 삭제 후 재생성
10
+ * confluence-sync init 대화형으로 .env 설정 파일 생성
11
+ * confluence-sync --help | --version
12
+ *
13
+ * git 이 원천(SoT)이며 Confluence 는 미러다. 양방향 동기화는 하지 않는다.
14
+ * 세부 로직은 ./markdown ./docs ./mapping ./confluence ./config ./init ./help 모듈에 있다.
15
+ */
16
+ import 'dotenv/config';
17
+ import { existsSync, readFileSync, statSync } from 'node:fs';
18
+ import { resolve, relative } from 'node:path';
19
+ import { readEnv, requireEnv } from './config.js';
20
+ import { toStorage, splitTitleAndBody, docHash } from './markdown.js';
21
+ import { collectMarkdown, buildTitleIndex, buildFolderIndex, parentKeyOf, containerKeyOf, sortForSync, resolveSelection, withParents, } from './docs.js';
22
+ import { loadMapping, saveMapping } from './mapping.js';
23
+ import { createClient } from './confluence.js';
24
+ import { runInit } from './init.js';
25
+ import { runPull } from './pull.js';
26
+ import { buildIgnorer } from './ignore.js';
27
+ import { printHelp, readPkgVersion } from './help.js';
28
+ import { bold, dim, red, green, yellow, magenta, cyan, gray } from './colors.js';
29
+ // ---- CLI 인자 ----
30
+ const argv = process.argv.slice(2);
31
+ const DRY_RUN = argv.includes('--dry-run');
32
+ const LIST = argv.includes('--list');
33
+ const REBUILD = argv.includes('--rebuild');
34
+ const FORCE = argv.includes('--force');
35
+ const VERIFY = argv.includes('--verify');
36
+ const HELP = argv.includes('--help') || argv.includes('-h');
37
+ const VERSION = argv.includes('--version') || argv.includes('-v');
38
+ function optVal(name) {
39
+ const i = argv.indexOf(name);
40
+ return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined;
41
+ }
42
+ /** 반복 가능한 옵션의 모든 값 수집(예: --exclude a --exclude b) */
43
+ function optVals(name) {
44
+ const out = [];
45
+ for (let i = 0; i < argv.length; i++)
46
+ if (argv[i] === name && i + 1 < argv.length)
47
+ out.push(argv[++i]);
48
+ return out;
49
+ }
50
+ const EXCLUDES = optVals('--exclude');
51
+ // 위치 인자(선택 경로/서브커맨드): 플래그·옵션값 제외
52
+ const positionals = [];
53
+ for (let i = 0; i < argv.length; i++) {
54
+ if (argv[i] === '--base' || argv[i] === '--mapping' || argv[i] === '--exclude') {
55
+ i++;
56
+ continue;
57
+ }
58
+ if (argv[i].startsWith('-'))
59
+ continue;
60
+ positionals.push(argv[i]);
61
+ }
62
+ // 인증·동기화 루트가 필요 없는 즉시 종료 모드 (base 검사보다 먼저)
63
+ if (HELP) {
64
+ printHelp();
65
+ process.exit(0);
66
+ }
67
+ if (VERSION) {
68
+ console.log(readPkgVersion());
69
+ process.exit(0);
70
+ }
71
+ if (positionals[0] === 'init') {
72
+ await runInit(argv);
73
+ process.exit(0);
74
+ }
75
+ if (positionals[0] === 'pull') {
76
+ await runPull(argv);
77
+ process.exit(0);
78
+ }
79
+ const baseInput = optVal('--base') ?? process.env.CONFLUENCE_SYNC_BASE;
80
+ if (!baseInput) {
81
+ console.error(red('✗ 동기화 루트가 지정되지 않았습니다.') + '\n' +
82
+ ' --base <dir> 인자 또는 env CONFLUENCE_SYNC_BASE 로 동기화할 디렉토리를 지정하세요.\n' +
83
+ ` 예) ${cyan('confluence-sync --base ./docs')}\n` +
84
+ ` 설정(.env)을 만들려면: ${cyan('confluence-sync init')}\n` +
85
+ ` 전체 도움말: ${cyan('confluence-sync --help')}`);
86
+ process.exit(1);
87
+ }
88
+ const BASE_DIR = resolve(baseInput);
89
+ // mapping 은 base 루트에 둔다(문서셋에 종속). --mapping 으로 위치 override 가능.
90
+ const mappingOpt = optVal('--mapping');
91
+ const MAPPING_PATH = mappingOpt ? resolve(mappingOpt) : resolve(BASE_DIR, '.confluence-sync.json');
92
+ const env = readEnv();
93
+ async function main() {
94
+ if (!existsSync(BASE_DIR) || !statSync(BASE_DIR).isDirectory()) {
95
+ console.error(red(`동기화 루트가 디렉토리가 아닙니다: ${BASE_DIR}`));
96
+ process.exit(1);
97
+ }
98
+ if (!DRY_RUN && !LIST)
99
+ requireEnv(env); // list/dry 는 Confluence 호출이 없어 인증 불필요
100
+ const ignorer = buildIgnorer(BASE_DIR, EXCLUDES);
101
+ const collected = collectMarkdown(BASE_DIR).map((f) => relative(BASE_DIR, f));
102
+ const allRel = collected.filter((r) => !ignorer.ignores(r)); // 제외된 문서는 모든 단계에서 빠짐
103
+ const ignoredCount = collected.length - allRel.length;
104
+ const folderIndex = buildFolderIndex(allRel);
105
+ const titleIndex = buildTitleIndex(allRel, BASE_DIR); // 링크 변환은 항상 전체 기준
106
+ const selected = positionals.length
107
+ ? withParents(resolveSelection(allRel, positionals, BASE_DIR), folderIndex)
108
+ : allRel;
109
+ const files = sortForSync(selected);
110
+ const scope = positionals.length ? `선택 ${files.length}/${allRel.length}건` : `${files.length}건`;
111
+ const ignoredNote = ignoredCount ? dim(` (제외 ${ignoredCount})`) : '';
112
+ console.log(`${dim('base:')} ${cyan(BASE_DIR)}\n${dim('대상:')} ${scope}${ignoredNote}`);
113
+ // ---- 경로 헬퍼 ----
114
+ const isReadmeFile = (p) => /README\.md$/.test(p);
115
+ const segOf = (s) => (s === '' ? 0 : s.split('/').length);
116
+ const dirOf = (p) => p.split('/').slice(0, -1).join('/');
117
+ const parentDirOf = (d) => (d.includes('/') ? d.slice(0, d.lastIndexOf('/')) : '');
118
+ const fileDepth = (rel) => (isReadmeFile(rel) ? Math.max(0, segOf(dirOf(rel)) - 1) : segOf(dirOf(rel)));
119
+ const folderDepth = (d) => Math.max(0, segOf(d) - 1);
120
+ // 파일의 README 없는(=폴더로 만들) 조상 dir들, shallow→deep
121
+ const ancestorFolderDirs = (rel) => {
122
+ const out = [];
123
+ for (let d = dirOf(rel); d !== ''; d = parentDirOf(d))
124
+ if (!folderIndex[d])
125
+ out.unshift(d);
126
+ return out;
127
+ };
128
+ // --list: base에서 인식된 문서·제목·계층만 출력(Confluence 호출 없음)
129
+ if (LIST) {
130
+ const seen = new Set();
131
+ for (const rel of files) {
132
+ for (const d of ancestorFolderDirs(rel))
133
+ if (!seen.has(d)) {
134
+ seen.add(d);
135
+ console.log(` ${' '.repeat(folderDepth(d))}${cyan(`📁 ${d}/`)}`);
136
+ }
137
+ console.log(` ${' '.repeat(fileDepth(rel))}${rel} ${dim('—')} ${cyan(`"${titleIndex[rel]}"`)}`);
138
+ }
139
+ return;
140
+ }
141
+ const mapping = loadMapping(MAPPING_PATH);
142
+ if (DRY_RUN) {
143
+ const seen = new Set();
144
+ for (const rel of files) {
145
+ for (const d of ancestorFolderDirs(rel))
146
+ if (!seen.has(d)) {
147
+ seen.add(d);
148
+ const fex = mapping[`${d}/`];
149
+ console.log(` [${fex?.pageId ? gray('폴더') : green('폴더+')}] 📁 ${d}/`);
150
+ }
151
+ const { title, body } = splitTitleAndBody(readFileSync(resolve(BASE_DIR, rel), 'utf8'), rel);
152
+ const r = toStorage(body, rel, titleIndex, BASE_DIR);
153
+ const hash = docHash(title, r);
154
+ const ex = mapping[rel];
155
+ const status = !ex?.pageId ? green('신규') : ex.hash === hash ? gray('동일') : yellow('변경');
156
+ const pk = parentKeyOf(rel, folderIndex);
157
+ console.log(` [${status}] ${rel} ${dim('→')} ${cyan(`"${title}"`)} ${dim(`(부모: ${pk ?? 'ROOT'}, 내부링크: ${r.internalLinks}, 이미지: ${r.images.length})`)}`);
158
+ }
159
+ console.log('\n' + dim('--dry-run: 실제 호출 없음.'));
160
+ return;
161
+ }
162
+ const client = createClient({ baseUrl: env.baseUrl, email: env.email, token: env.token }, { force: FORCE, verify: VERIFY });
163
+ const spaceId = await client.getSpaceId(env.spaceKey);
164
+ if (REBUILD) {
165
+ await client.deleteAll(mapping);
166
+ saveMapping(MAPPING_PATH, {});
167
+ }
168
+ const work = REBUILD ? {} : mapping;
169
+ let created = 0, updated = 0, skipped = 0, recreated = 0, relinked = 0, foldersMade = 0;
170
+ const rendered = new Map();
171
+ const recreatedTitles = new Set(); // 이번에 재생성된 문서의 제목
172
+ const touched = new Set(); // 이번에 발행(생성/갱신/재생성)된 문서
173
+ const handledFolders = new Set(); // 이번 실행에서 처리한 폴더 dir
174
+ // README 없는 폴더를 Confluence 폴더로 생성(없으면)하고 work 에 등록. 성공 여부 반환.
175
+ const ensureFolder = async (dir) => {
176
+ const key = `${dir}/`;
177
+ const pcKey = containerKeyOf(parentDirOf(dir), folderIndex);
178
+ const parentId = pcKey ? work[pcKey]?.pageId : env.parentId;
179
+ if (pcKey && !parentId) {
180
+ console.error(yellow(` ✗ 폴더 건너뜀 ${dir}/ (부모 '${pcKey}' 가 아직 없음)`));
181
+ return false;
182
+ }
183
+ const ex = work[key];
184
+ if (ex?.pageId && ex.type === 'folder' && (await client.getContentOrNull(ex.pageId))) {
185
+ return true; // 이미 존재
186
+ }
187
+ try {
188
+ const title = dir.split('/').pop();
189
+ const fid = await client.createFolder(spaceId, title, parentId);
190
+ work[key] = { pageId: fid, title, type: 'folder' };
191
+ saveMapping(MAPPING_PATH, work);
192
+ console.log(` ${cyan('📁 폴더')} ${dir}/ ${dim(`(부모: ${pcKey ?? 'ROOT'})`)}`);
193
+ foldersMade++;
194
+ return true;
195
+ }
196
+ catch (e) {
197
+ console.error(red(` ✗ 폴더 생성 실패 ${dir}/`) + `\n${e.message}`);
198
+ return false;
199
+ }
200
+ };
201
+ for (const rel of files) {
202
+ // 상위 README-less 폴더들 보장(shallow→deep)
203
+ let folderOk = true;
204
+ for (const d of ancestorFolderDirs(rel)) {
205
+ if (!handledFolders.has(d)) {
206
+ handledFolders.add(d);
207
+ if (!(await ensureFolder(d)))
208
+ folderOk = false;
209
+ }
210
+ else if (!work[`${d}/`]?.pageId)
211
+ folderOk = false;
212
+ if (!folderOk)
213
+ break;
214
+ }
215
+ if (!folderOk) {
216
+ console.error(yellow(` ✗ 건너뜀 ${rel} (상위 폴더 미생성)`));
217
+ continue;
218
+ }
219
+ const { title, body } = splitTitleAndBody(readFileSync(resolve(BASE_DIR, rel), 'utf8'), rel);
220
+ const r = toStorage(body, rel, titleIndex, BASE_DIR);
221
+ const hash = docHash(title, r);
222
+ const pk = parentKeyOf(rel, folderIndex);
223
+ const parentId = pk ? work[pk]?.pageId : env.parentId;
224
+ rendered.set(rel, { ...r, title, hash, parentId });
225
+ if (pk && !parentId) {
226
+ console.error(yellow(` ✗ 건너뜀 ${rel} (부모 '${pk}' 가 아직 없음)`));
227
+ continue;
228
+ }
229
+ try {
230
+ const result = await client.upsertPage(work, spaceId, rel, title, r.storage, hash, parentId);
231
+ saveMapping(MAPPING_PATH, work);
232
+ if (result === 'skipped') {
233
+ console.log(` ${gray('= 변경없음')} ${rel}`);
234
+ skipped++;
235
+ continue;
236
+ }
237
+ const imgOk = await client.uploadImages(work[rel].pageId, r.images, BASE_DIR);
238
+ const imgNote = r.images.length ? `, 이미지 ${imgOk}/${r.images.length}` : '';
239
+ const label = result === 'created' ? green('+ 생성')
240
+ : result === 'recreated' ? magenta('♻ 재생성(삭제 복구)')
241
+ : yellow('↻ 갱신');
242
+ console.log(` ${label} ${rel} ${dim('→')} ${cyan(`"${title}"`)} ${dim(`(부모: ${pk ?? 'ROOT'}${imgNote})`)}`);
243
+ if (result === 'created')
244
+ created++;
245
+ else if (result === 'recreated') {
246
+ recreated++;
247
+ recreatedTitles.add(title);
248
+ }
249
+ else
250
+ updated++;
251
+ touched.add(rel);
252
+ }
253
+ catch (e) {
254
+ console.error(red(` ✗ 실패 ${rel}`) + `\n${e.message}`);
255
+ }
256
+ }
257
+ // 링크 정합성: 재생성된 문서를 가리키던, 이번에 발행 안 된(스킵된) 문서를 강제 재발행해 링크를 새 페이지로 재연결
258
+ if (recreatedTitles.size) {
259
+ for (const rel of files) {
260
+ if (touched.has(rel))
261
+ continue;
262
+ const r = rendered.get(rel);
263
+ if (!r || !work[rel]?.pageId)
264
+ continue;
265
+ if (!r.linkedTitles.some((t) => recreatedTitles.has(t)))
266
+ continue;
267
+ try {
268
+ await client.upsertPage(work, spaceId, rel, r.title, r.storage, r.hash, r.parentId, true);
269
+ saveMapping(MAPPING_PATH, work);
270
+ await client.uploadImages(work[rel].pageId, r.images, BASE_DIR);
271
+ console.log(` ${cyan('🔗 링크 재연결')} ${rel} ${dim('→')} ${cyan(`"${r.title}"`)}`);
272
+ relinked++;
273
+ }
274
+ catch (e) {
275
+ console.error(red(` ✗ 링크 재연결 실패 ${rel}`) + `\n${e.message}`);
276
+ }
277
+ }
278
+ }
279
+ console.log('\n' + bold('완료') +
280
+ ` ${green(`생성 ${created}`)}` +
281
+ ` ${yellow(`갱신 ${updated}`)}` +
282
+ ` ${magenta(`재생성 ${recreated}`)}` +
283
+ ` ${cyan(`링크재연결 ${relinked}`)}` +
284
+ ` ${gray(`변경없음 ${skipped}`)}` +
285
+ (foldersMade ? ` ${cyan(`폴더 ${foldersMade}`)}` : '') +
286
+ ` ${dim(`대상 ${files.length}`)}`);
287
+ }
288
+ main().catch((e) => { console.error(e); process.exit(1); });
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@wonseok-han/confluence-sync",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "Confluence Cloud ↔ Markdown 양방향 동기화 CLI — 마크다운을 Confluence 페이지로 발행(push)하고, Confluence 페이지·폴더·스페이스를 마크다운으로 가져옵니다(pull).",
6
+ "keywords": [
7
+ "confluence",
8
+ "markdown",
9
+ "sync",
10
+ "cli",
11
+ "atlassian",
12
+ "docs-as-code",
13
+ "obsidian"
14
+ ],
15
+ "license": "MIT",
16
+ "author": "wonseok-han",
17
+ "homepage": "https://github.com/wonseok-han/confluence-sync#readme",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/wonseok-han/confluence-sync.git"
21
+ },
22
+ "bugs": {
23
+ "url": "https://github.com/wonseok-han/confluence-sync/issues"
24
+ },
25
+ "engines": {
26
+ "node": ">=20"
27
+ },
28
+ "bin": {
29
+ "confluence-sync": "dist/sync.js"
30
+ },
31
+ "files": [
32
+ "dist",
33
+ "README.md",
34
+ "LICENSE"
35
+ ],
36
+ "scripts": {
37
+ "build": "tsc",
38
+ "prepublishOnly": "npm run build",
39
+ "sync": "tsx src/sync.ts",
40
+ "sync:dry": "tsx src/sync.ts --dry-run",
41
+ "sync:rebuild": "tsx src/sync.ts --rebuild",
42
+ "list": "tsx src/sync.ts --list"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
47
+ "dependencies": {
48
+ "dotenv": "^16.4.5",
49
+ "ignore": "^7.0.5",
50
+ "markdown-it": "^14.1.0",
51
+ "turndown": "^7.2.4"
52
+ },
53
+ "devDependencies": {
54
+ "@types/markdown-it": "^14.1.2",
55
+ "@types/node": "^22.0.0",
56
+ "@types/turndown": "^5.0.6",
57
+ "tsx": "^4.19.0",
58
+ "typescript": "^5.6.0"
59
+ }
60
+ }