@bestend/confluence-cli 1.15.8 → 1.16.1

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/AGENTS.md ADDED
@@ -0,0 +1,105 @@
1
+ # Confluence CLI — 프로젝트 가이드
2
+
3
+ ## 아키텍처
4
+
5
+ ```
6
+ bin/
7
+ index.js # 엔트리포인트 (Node 버전 체크 → confluence.js 로딩)
8
+ confluence.js # Commander CLI 정의 (모든 커맨드 등록)
9
+
10
+ lib/
11
+ confluence-client.js # 핵심 클라이언트 (API 호출, 포맷 변환, 페이지 CRUD)
12
+ config.js # 설정 로드 (~/.confluence-cli/config.json)
13
+ analytics.js # 익명 사용 통계
14
+
15
+ tests/
16
+ confluence-client.test.js # Jest 단위 테스트
17
+
18
+ .github/workflows/
19
+ ci.yml # push/PR → lint + test (Node 18.x, 20.x)
20
+ publish.yml # tag push (v*) → npm publish with OIDC provenance
21
+ ```
22
+
23
+ ## 코드 스타일
24
+
25
+ - ESLint flat config (`eslint.config.js`)
26
+ - 들여쓰기: 2칸 스페이스
27
+ - 따옴표: 작은따옴표
28
+ - 세미콜론: 필수
29
+ - `no-unused-vars`: `_` 접두사 무시
30
+
31
+ ## 핵심 변환 파이프라인
32
+
33
+ ```
34
+ Markdown → MarkdownIt.render() → HTML → htmlToConfluenceStorage() → Storage XML
35
+ ```
36
+
37
+ `htmlToConfluenceStorage()`는 MarkdownIt이 생성한 HTML 중 Confluence 전용 처리가 필요한 것만 변환:
38
+ - `<li>` → `<li><p>...</p></li>` (Confluence 필수)
39
+ - `<pre><code>` → `ac:structured-macro` code block with CDATA
40
+ - `<blockquote>` → info/warning/note 매크로
41
+ - `<th>`, `<td>` → `<p>` 래핑
42
+ - `<hr>` → `<hr />` (self-closing)
43
+
44
+ 나머지 HTML 태그(h1-h6, p, strong, em, ul, ol, table 등)는 이미 Confluence storage와 호환되므로 변환하지 않음.
45
+
46
+ ## 버전 관리
47
+
48
+ - `package.json`의 version은 직접 올리지 않아도 됨
49
+ - publish.yml이 git tag에서 버전을 추출해서 package.json을 자동 맞춤
50
+ - 단, 로컬 개발 시 혼동 방지를 위해 릴리스 전 맞춰두는 것을 권장
51
+
52
+ ## 릴리스 절차
53
+
54
+ ```bash
55
+ # 1. 변경 커밋 (Conventional Commits)
56
+ git add -A
57
+ git commit -m "feat: add --parent option to create command"
58
+
59
+ # 2. 태그 생성 (SemVer)
60
+ git tag v1.16.0
61
+
62
+ # 3. 푸시 (커밋 + 태그)
63
+ git push origin main --follow-tags
64
+ ```
65
+
66
+ 태그 push 시 자동으로:
67
+ 1. `ci.yml` → lint + test (push to main 트리거)
68
+ 2. `publish.yml` → npm publish with OIDC provenance (tag 트리거)
69
+
70
+ ### 버전 결정 기준
71
+
72
+ | 변경 유형 | 예시 | 버전 |
73
+ |-----------|------|------|
74
+ | 새 명령어/옵션 추가 | `--parent` 옵션 | minor (x.Y.0) |
75
+ | 버그 수정, 내부 정리 | no-op regex 제거 | patch (x.y.Z) |
76
+ | 하위 호환 깨짐 | 명령어 인터페이스 변경 | major (X.0.0) |
77
+
78
+ ### npm OIDC Trusted Publishing
79
+
80
+ publish.yml은 `id-token: write` 권한으로 npm OIDC provenance를 사용.
81
+ npm 토큰이 아닌 GitHub Actions OIDC로 인증하므로 시크릿 관리 불필요.
82
+ Node.js 24.x (npm 11.5.1+) 필요.
83
+
84
+ ## 테스트
85
+
86
+ ```bash
87
+ npm test # Jest 단위 테스트 (40개)
88
+ npm run lint # ESLint
89
+ ```
90
+
91
+ 테스트는 `axios-mock-adapter`로 HTTP 모킹. 실제 Confluence 인스턴스 불필요.
92
+
93
+ ## agent-rules 연동
94
+
95
+ 이 CLI는 [mona/agent-rules](https://oss.navercorp.com/mona/agent-rules)의 `confluence-cli` 스킬로 사용됨:
96
+ - 스킬 문서: `sources/skills/confluence-cli/SKILL.md`
97
+ - 설치: `npm install -g @bestend/confluence-cli@latest`
98
+
99
+ CLI 버전 릴리스 후 agent-rules의 SKILL.md `metadata.version`도 맞춰 업데이트할 것.
100
+
101
+ ## 금지 사항
102
+
103
+ - `htmlToConfluenceStorage()`에 no-op regex 추가 금지 (X→X 변환은 의미 없음)
104
+ - 전역 entity unescape (`&lt;`→`<` 등) 금지 — code block 내부는 이미 별도 디코딩됨
105
+ - OIDC publish 설정 변경 시 Node.js 24.x 이상 유지 필수
package/bin/confluence.js CHANGED
@@ -141,11 +141,12 @@ program
141
141
 
142
142
  // Create command
143
143
  program
144
- .command('create <title> <spaceKey>')
144
+ .command('create <title> [spaceKey]')
145
145
  .description('Create a new Confluence page')
146
146
  .option('-f, --file <file>', 'Read content from file')
147
147
  .option('-c, --content <content>', 'Page content as string')
148
148
  .option('--format <format>', 'Content format (storage, html, markdown)', 'storage')
149
+ .option('--parent <parentId>', 'Parent page ID (creates page as child of this page)')
149
150
  .option('--validate-storage', 'Validate storage content (XML well-formed) before sending')
150
151
  .option('--no-sanitize-storage', 'Disable auto-escaping raw ampersands in storage format')
151
152
  .action(async (title, spaceKey, options) => {
@@ -168,17 +169,33 @@ program
168
169
  throw new Error('Either --file or --content option is required');
169
170
  }
170
171
 
171
- const result = await client.createPage(title, spaceKey, content, options.format, {
172
- validateStorage: options.validateStorage,
173
- sanitizeStorage: options.sanitizeStorage
174
- });
175
-
176
- console.log(chalk.green('✅ Page created successfully!'));
177
- console.log(`Title: ${chalk.blue(result.title)}`);
178
- console.log(`ID: ${chalk.blue(result.id)}`);
172
+ let result;
173
+ if (options.parent) {
174
+ const parentInfo = await client.getPageInfo(options.parent);
175
+ const derivedSpaceKey = parentInfo.space.key;
176
+ result = await client.createChildPage(title, derivedSpaceKey, options.parent, content, options.format, {
177
+ validateStorage: options.validateStorage,
178
+ sanitizeStorage: options.sanitizeStorage
179
+ });
180
+ console.log(chalk.green('✅ Page created successfully!'));
181
+ console.log(`Title: ${chalk.blue(result.title)}`);
182
+ console.log(`ID: ${chalk.blue(result.id)}`);
183
+ console.log(`Parent: ${chalk.blue(parentInfo.title)} (${options.parent})`);
184
+ } else {
185
+ if (!spaceKey) {
186
+ throw new Error('Space key is required when --parent is not specified');
187
+ }
188
+ result = await client.createPage(title, spaceKey, content, options.format, {
189
+ validateStorage: options.validateStorage,
190
+ sanitizeStorage: options.sanitizeStorage
191
+ });
192
+ console.log(chalk.green('✅ Page created successfully!'));
193
+ console.log(`Title: ${chalk.blue(result.title)}`);
194
+ console.log(`ID: ${chalk.blue(result.id)}`);
195
+ }
179
196
  console.log(`Space: ${chalk.blue(result.space.name)} (${result.space.key})`);
180
- console.log(`URL: ${chalk.gray(`https://${config.domain}/wiki${result._links.webui}`)}`);
181
-
197
+ console.log(`URL: ${chalk.gray(client.pageUrl(result._links.webui))}`);
198
+
182
199
  analytics.track('create', true);
183
200
  } catch (error) {
184
201
  analytics.track('create', false);
@@ -232,8 +249,8 @@ program
232
249
  console.log(`ID: ${chalk.blue(result.id)}`);
233
250
  console.log(`Parent: ${chalk.blue(parentInfo.title)} (${parentId})`);
234
251
  console.log(`Space: ${chalk.blue(result.space.name)} (${result.space.key})`);
235
- console.log(`URL: ${chalk.gray(`https://${config.domain}/wiki${result._links.webui}`)}`);
236
-
252
+ console.log(`URL: ${chalk.gray(client.pageUrl(result._links.webui))}`);
253
+
237
254
  analytics.track('create_child', true);
238
255
  } catch (error) {
239
256
  analytics.track('create_child', false);
@@ -284,8 +301,8 @@ program
284
301
  console.log(`Title: ${chalk.blue(result.title)}`);
285
302
  console.log(`ID: ${chalk.blue(result.id)}`);
286
303
  console.log(`Version: ${chalk.blue(result.version.number)}`);
287
- console.log(`URL: ${chalk.gray(`https://${config.domain}/wiki${result._links.webui}`)}`);
288
-
304
+ console.log(`URL: ${chalk.gray(client.pageUrl(result._links.webui))}`);
305
+
289
306
  analytics.track('update', true);
290
307
  } catch (error) {
291
308
  analytics.track('update', false);
@@ -390,8 +407,8 @@ program
390
407
  console.log(`Title: ${chalk.green(pageInfo.title)}`);
391
408
  console.log(`ID: ${chalk.green(pageInfo.id)}`);
392
409
  console.log(`Space: ${chalk.green(pageInfo.space.name)} (${pageInfo.space.key})`);
393
- console.log(`URL: ${chalk.gray(`https://${config.domain}/wiki${pageInfo.url}`)}`);
394
-
410
+ console.log(`URL: ${chalk.gray(client.pageUrl(pageInfo.url))}`);
411
+
395
412
  analytics.track('find', true);
396
413
  } catch (error) {
397
414
  analytics.track('find', false);
@@ -1067,7 +1084,7 @@ program
1067
1084
  console.log(` - ...and ${result.failures.length - 10} more`);
1068
1085
  }
1069
1086
  }
1070
- console.log(`URL: ${chalk.gray(`https://${config.domain}/wiki${result.rootPage._links.webui}`)}`);
1087
+ console.log(`URL: ${chalk.gray(client.pageUrl(result.rootPage._links.webui))}`);
1071
1088
  if (options.failOnError && result.failures?.length) {
1072
1089
  analytics.track('copy_tree', false);
1073
1090
  console.error(chalk.red('Completed with failures and --fail-on-error is set.'));
@@ -1129,7 +1146,7 @@ program
1129
1146
  type: page.type,
1130
1147
  status: page.status,
1131
1148
  spaceKey: page.space?.key,
1132
- url: `https://${config.domain}/wiki/spaces/${page.space?.key}/pages/${page.id}`,
1149
+ url: client.pageUrl(`/spaces/${page.space?.key}/pages/${page.id}`),
1133
1150
  parentId: page.parentId || resolvedPageId
1134
1151
  }))
1135
1152
  };
@@ -1141,7 +1158,7 @@ program
1141
1158
 
1142
1159
  // Build tree structure
1143
1160
  const tree = buildTree(children, resolvedPageId);
1144
- printTree(tree, config, options, 1);
1161
+ printTree(tree, client, options, 1);
1145
1162
 
1146
1163
  console.log('');
1147
1164
  console.log(chalk.gray(`Total: ${children.length} child page${children.length === 1 ? '' : 's'}`));
@@ -1158,7 +1175,7 @@ program
1158
1175
  }
1159
1176
 
1160
1177
  if (options.showUrl) {
1161
- const url = `https://${config.domain}/wiki/spaces/${page.space?.key}/pages/${page.id}`;
1178
+ const url = client.pageUrl(`/spaces/${page.space?.key}/pages/${page.id}`);
1162
1179
  output += `\n ${chalk.gray(url)}`;
1163
1180
  }
1164
1181
 
@@ -1210,27 +1227,27 @@ function buildTree(pages, rootId) {
1210
1227
  }
1211
1228
 
1212
1229
  // Helper function to print tree
1213
- function printTree(nodes, config, options, depth = 1) {
1230
+ function printTree(nodes, client, options, depth = 1) {
1214
1231
  nodes.forEach((node, index) => {
1215
1232
  const isLast = index === nodes.length - 1;
1216
1233
  const indent = ' '.repeat(depth - 1);
1217
1234
  const prefix = isLast ? '└── ' : '├── ';
1218
-
1235
+
1219
1236
  let output = `${indent}${prefix}📄 ${chalk.green(node.title)}`;
1220
-
1237
+
1221
1238
  if (options.showId) {
1222
1239
  output += ` ${chalk.gray(`(ID: ${node.id})`)}`;
1223
1240
  }
1224
-
1241
+
1225
1242
  if (options.showUrl) {
1226
- const url = `https://${config.domain}/wiki/spaces/${node.space?.key}/pages/${node.id}`;
1243
+ const url = client.pageUrl(`/spaces/${node.space?.key}/pages/${node.id}`);
1227
1244
  output += `\n${indent}${isLast ? ' ' : '│ '}${chalk.gray(url)}`;
1228
1245
  }
1229
-
1246
+
1230
1247
  console.log(output);
1231
-
1248
+
1232
1249
  if (node.children && node.children.length > 0) {
1233
- printTree(node.children, config, options, depth + 1);
1250
+ printTree(node.children, client, options, depth + 1);
1234
1251
  }
1235
1252
  });
1236
1253
  }
@@ -23,6 +23,25 @@ class ConfluenceClient {
23
23
  baseURL: this.baseURL,
24
24
  headers
25
25
  });
26
+
27
+ // Auto-detect context path (e.g., "/wiki" or "") from first API response
28
+ this._contextPath = null;
29
+ this.client.interceptors.response.use((response) => {
30
+ if (this._contextPath === null && response.data?._links?.context !== undefined) {
31
+ this._contextPath = response.data._links.context;
32
+ }
33
+ return response;
34
+ });
35
+ }
36
+
37
+ /**
38
+ * Build a full page URL from a webui path (e.g., /spaces/SPACE/pages/123)
39
+ * Context path is auto-detected from API responses.
40
+ */
41
+ pageUrl(path) {
42
+ if (!path) return '';
43
+ const context = this._contextPath || '';
44
+ return `https://${this.domain}${context}${path}`;
26
45
  }
27
46
 
28
47
  sanitizeStorageContent(content, options = {}) {
@@ -373,7 +392,7 @@ class ConfluenceClient {
373
392
  const webui = page._links?.webui || '';
374
393
  return {
375
394
  title: page.title,
376
- url: webui ? `https://${this.domain}/wiki${webui}` : ''
395
+ url: this.pageUrl(webui)
377
396
  };
378
397
  }
379
398
  return null;
@@ -467,7 +486,7 @@ class ConfluenceClient {
467
486
  // Format: - [Page Title](URL)
468
487
  const childPagesList = childPages.map(page => {
469
488
  const webui = page._links?.webui || '';
470
- const url = webui ? `https://${this.domain}/wiki${webui}` : '';
489
+ const url = this.pageUrl(webui);
471
490
  if (url) {
472
491
  return `- [${page.title}](${url})`;
473
492
  } else {
@@ -853,27 +872,18 @@ class ConfluenceClient {
853
872
  * Convert HTML to native Confluence storage format
854
873
  */
855
874
  htmlToConfluenceStorage(html) {
875
+ // MarkdownIt already produces valid HTML tags (h1-h6, p, strong, em, ul, ol, table, etc.)
876
+ // This function only transforms elements that need Confluence-specific handling:
877
+ // - li content wrapped in p (Confluence requirement)
878
+ // - pre/code → ac:structured-macro code blocks with CDATA
879
+ // - blockquote → info/warning/note macros
880
+ // - th/td content wrapped in p
881
+ // - hr → self-closing hr /
856
882
  let storage = html;
857
883
 
858
- // Convert headings to native Confluence format
859
- storage = storage.replace(/<h([1-6])>(.*?)<\/h[1-6]>/g, '<h$1>$2</h$1>');
860
-
861
- // Convert paragraphs
862
- storage = storage.replace(/<p>(.*?)<\/p>/g, '<p>$1</p>');
863
-
864
- // Convert strong/bold text
865
- storage = storage.replace(/<strong>(.*?)<\/strong>/g, '<strong>$1</strong>');
866
-
867
- // Convert emphasis/italic text
868
- storage = storage.replace(/<em>(.*?)<\/em>/g, '<em>$1</em>');
869
-
870
- // Convert unordered lists
871
- storage = storage.replace(/<ul>(.*?)<\/ul>/gs, '<ul>$1</ul>');
884
+ // Wrap li content in p tags (Confluence requirement)
872
885
  storage = storage.replace(/<li>(.*?)<\/li>/g, '<li><p>$1</p></li>');
873
886
 
874
- // Convert ordered lists
875
- storage = storage.replace(/<ol>(.*?)<\/ol>/gs, '<ol>$1</ol>');
876
-
877
887
  // Convert code blocks to Confluence code macro
878
888
  storage = storage.replace(/<pre><code(?:\s+class="language-(\w+)")?>(.*?)<\/code><\/pre>/gs, (_, lang, code) => {
879
889
  const language = lang || 'text';
@@ -892,9 +902,6 @@ class ConfluenceClient {
892
902
  </ac:structured-macro>`;
893
903
  });
894
904
 
895
- // Convert inline code
896
- storage = storage.replace(/<code>(.*?)<\/code>/g, '<code>$1</code>');
897
-
898
905
  // Convert blockquotes to appropriate macros based on content
899
906
  storage = storage.replace(/<blockquote>(.*?)<\/blockquote>/gs, (_, content) => {
900
907
  // Check for admonition patterns
@@ -921,11 +928,7 @@ class ConfluenceClient {
921
928
  }
922
929
  });
923
930
 
924
- // Convert tables
925
- storage = storage.replace(/<table>(.*?)<\/table>/gs, '<table>$1</table>');
926
- storage = storage.replace(/<thead>(.*?)<\/thead>/gs, '<thead>$1</thead>');
927
- storage = storage.replace(/<tbody>(.*?)<\/tbody>/gs, '<tbody>$1</tbody>');
928
- storage = storage.replace(/<tr>(.*?)<\/tr>/gs, '<tr>$1</tr>');
931
+ // Wrap th/td content in p tags (Confluence requirement)
929
932
  storage = storage.replace(/<th>(.*?)<\/th>/g, '<th><p>$1</p></th>');
930
933
  storage = storage.replace(/<td>(.*?)<\/td>/g, '<td><p>$1</p></td>');
931
934
 
@@ -934,9 +937,6 @@ class ConfluenceClient {
934
937
  // Convert horizontal rules
935
938
  storage = storage.replace(/<hr\s*\/?>/g, '<hr />');
936
939
 
937
- // Clean up any remaining HTML entities and normalize whitespace
938
- storage = storage.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
939
-
940
940
  return storage;
941
941
  }
942
942
 
@@ -1154,12 +1154,12 @@ class ConfluenceClient {
1154
1154
  markdown = markdown.replace(/<ac:structured-macro ac:name="include"[^>]*>[\s\S]*?<ac:parameter ac:name="">[\s\S]*?<ac:link>[\s\S]*?<ri:page\s+ri:space-key="([^"]+)"\s+ri:content-title="([^"]+)"[^>]*\/>[\s\S]*?<\/ac:link>[\s\S]*?<\/ac:parameter>[\s\S]*?<\/ac:structured-macro>/g, (_, spaceKey, title) => {
1155
1155
  // Try to build a proper URL - if spaceKey starts with ~, it's a user space
1156
1156
  if (spaceKey.startsWith('~')) {
1157
- const spacePath = `display/${spaceKey}/${encodeURIComponent(title)}`;
1158
- return `\n> 📄 **${labels.includePage}**: [${title}](https://${this.domain}/wiki/${spacePath})\n`;
1157
+ const spacePath = `/display/${spaceKey}/${encodeURIComponent(title)}`;
1158
+ return `\n> 📄 **${labels.includePage}**: [${title}](${this.pageUrl(spacePath)})\n`;
1159
1159
  } else {
1160
1160
  // For non-user spaces, we cannot construct a valid link without the page ID.
1161
1161
  // Document that manual correction is required.
1162
- return `\n> 📄 **${labels.includePage}**: [${title}](https://${this.domain}/wiki/spaces/${spaceKey}/pages/[PAGE_ID_HERE]) _(manual link correction required)_\n`;
1162
+ return `\n> 📄 **${labels.includePage}**: [${title}](${this.pageUrl(`/spaces/${spaceKey}/pages/[PAGE_ID_HERE]`)}) _(manual link correction required)_\n`;
1163
1163
  }
1164
1164
  });
1165
1165
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bestend/confluence-cli",
3
- "version": "1.15.8",
3
+ "version": "1.16.1",
4
4
  "description": "A command-line interface for Atlassian Confluence with page creation and editing capabilities",
5
5
  "main": "index.js",
6
6
  "bin": {