@bestend/confluence-cli 1.16.0 → 2.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/.claude/skills/confluence/SKILL.md +722 -0
- package/README.md +251 -21
- package/bin/confluence.js +836 -116
- package/bin/index.js +6 -1
- package/lib/config.js +242 -40
- package/lib/confluence-client.js +309 -52
- package/package.json +10 -4
- package/.eslintrc.js +0 -23
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -34
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -26
- package/.github/ISSUE_TEMPLATE/feedback.md +0 -37
- package/.github/pull_request_template.md +0 -31
- package/.github/workflows/ci.yml +0 -28
- package/.github/workflows/publish.yml +0 -38
- package/.releaserc +0 -17
- package/AGENTS.md +0 -105
- package/CHANGELOG.md +0 -232
- package/CONTRIBUTING.md +0 -246
- package/docs/PROMOTION.md +0 -63
- package/eslint.config.js +0 -33
- package/examples/copy-tree-example.sh +0 -117
- package/examples/create-child-page-example.sh +0 -67
- package/examples/demo-page-management.sh +0 -68
- package/examples/demo.sh +0 -43
- package/examples/sample-page.md +0 -30
- package/jest.config.js +0 -13
- package/llms.txt +0 -46
- package/tests/confluence-client.test.js +0 -458
package/bin/confluence.js
CHANGED
|
@@ -4,26 +4,48 @@ const { program } = require('commander');
|
|
|
4
4
|
const chalk = require('chalk');
|
|
5
5
|
const inquirer = require('inquirer');
|
|
6
6
|
const ConfluenceClient = require('../lib/confluence-client');
|
|
7
|
-
const { getConfig, initConfig } = require('../lib/config');
|
|
7
|
+
const { getConfig, initConfig, listProfiles, setActiveProfile, deleteProfile, isValidProfileName } = require('../lib/config');
|
|
8
8
|
const Analytics = require('../lib/analytics');
|
|
9
9
|
const pkg = require('../package.json');
|
|
10
10
|
|
|
11
|
+
function buildPageUrl(config, path) {
|
|
12
|
+
const protocol = config.protocol || 'https';
|
|
13
|
+
return `${protocol}://${config.domain}${path}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function assertWritable(config) {
|
|
17
|
+
if (config.readOnly) {
|
|
18
|
+
console.error(chalk.red('Error: This profile is in read-only mode. Write operations are not allowed.'));
|
|
19
|
+
console.error(chalk.yellow('Tip: Use "confluence profile add <name>" without --read-only, or set readOnly to false in config.'));
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
11
24
|
program
|
|
12
25
|
.name('confluence')
|
|
13
26
|
.description('CLI tool for Atlassian Confluence')
|
|
14
|
-
.version(pkg.version)
|
|
27
|
+
.version(pkg.version)
|
|
28
|
+
.option('--profile <name>', 'Use a specific configuration profile');
|
|
29
|
+
|
|
30
|
+
// Helper: resolve profile name from global --profile flag
|
|
31
|
+
function getProfileName() {
|
|
32
|
+
return program.opts().profile || undefined;
|
|
33
|
+
}
|
|
15
34
|
|
|
16
35
|
// Init command
|
|
17
36
|
program
|
|
18
37
|
.command('init')
|
|
19
38
|
.description('Initialize Confluence CLI configuration')
|
|
20
39
|
.option('-d, --domain <domain>', 'Confluence domain')
|
|
40
|
+
.option('--protocol <protocol>', 'Protocol (http or https)')
|
|
21
41
|
.option('-p, --api-path <path>', 'REST API path')
|
|
22
42
|
.option('-a, --auth-type <type>', 'Authentication type (basic or bearer)')
|
|
23
|
-
.option('-e, --email <email>', 'Email for basic auth')
|
|
43
|
+
.option('-e, --email <email>', 'Email or username for basic auth')
|
|
24
44
|
.option('-t, --token <token>', 'API token')
|
|
45
|
+
.option('--read-only', 'Set profile to read-only mode (blocks write operations)')
|
|
25
46
|
.action(async (options) => {
|
|
26
|
-
|
|
47
|
+
const profile = getProfileName();
|
|
48
|
+
await initConfig({ ...options, profile });
|
|
27
49
|
});
|
|
28
50
|
|
|
29
51
|
// Read command
|
|
@@ -34,7 +56,7 @@ program
|
|
|
34
56
|
.action(async (pageId, options) => {
|
|
35
57
|
const analytics = new Analytics();
|
|
36
58
|
try {
|
|
37
|
-
const client = new ConfluenceClient(getConfig());
|
|
59
|
+
const client = new ConfluenceClient(getConfig(getProfileName()));
|
|
38
60
|
const content = await client.readPage(pageId, options.format);
|
|
39
61
|
console.log(content);
|
|
40
62
|
analytics.track('read', true);
|
|
@@ -52,7 +74,7 @@ program
|
|
|
52
74
|
.action(async (pageId) => {
|
|
53
75
|
const analytics = new Analytics();
|
|
54
76
|
try {
|
|
55
|
-
const client = new ConfluenceClient(getConfig());
|
|
77
|
+
const client = new ConfluenceClient(getConfig(getProfileName()));
|
|
56
78
|
const info = await client.getPageInfo(pageId);
|
|
57
79
|
console.log(chalk.blue('Page Information:'));
|
|
58
80
|
console.log(`Title: ${chalk.green(info.title)}`);
|
|
@@ -75,11 +97,12 @@ program
|
|
|
75
97
|
.command('search <query>')
|
|
76
98
|
.description('Search for Confluence pages')
|
|
77
99
|
.option('-l, --limit <limit>', 'Limit number of results', '10')
|
|
100
|
+
.option('--cql', 'Pass query as raw CQL instead of text search')
|
|
78
101
|
.action(async (query, options) => {
|
|
79
102
|
const analytics = new Analytics();
|
|
80
103
|
try {
|
|
81
|
-
const client = new ConfluenceClient(getConfig());
|
|
82
|
-
const results = await client.search(query, parseInt(options.limit));
|
|
104
|
+
const client = new ConfluenceClient(getConfig(getProfileName()));
|
|
105
|
+
const results = await client.search(query, parseInt(options.limit), options.cql);
|
|
83
106
|
|
|
84
107
|
if (results.length === 0) {
|
|
85
108
|
console.log(chalk.yellow('No results found.'));
|
|
@@ -109,7 +132,7 @@ program
|
|
|
109
132
|
.action(async () => {
|
|
110
133
|
const analytics = new Analytics();
|
|
111
134
|
try {
|
|
112
|
-
const config = getConfig();
|
|
135
|
+
const config = getConfig(getProfileName());
|
|
113
136
|
const client = new ConfluenceClient(config);
|
|
114
137
|
const spaces = await client.getSpaces();
|
|
115
138
|
|
|
@@ -139,22 +162,64 @@ program
|
|
|
139
162
|
}
|
|
140
163
|
});
|
|
141
164
|
|
|
165
|
+
// Install skill command
|
|
166
|
+
program
|
|
167
|
+
.command('install-skill')
|
|
168
|
+
.description('Copy Claude Code skill files into your project\'s .claude/skills/ directory')
|
|
169
|
+
.option('--dest <directory>', 'Target directory', './.claude/skills/confluence')
|
|
170
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
171
|
+
.action(async (options) => {
|
|
172
|
+
const fs = require('fs');
|
|
173
|
+
const path = require('path');
|
|
174
|
+
|
|
175
|
+
const skillSrc = path.join(__dirname, '..', '.claude', 'skills', 'confluence', 'SKILL.md');
|
|
176
|
+
|
|
177
|
+
if (!fs.existsSync(skillSrc)) {
|
|
178
|
+
console.error(chalk.red('Error: skill file not found in package. Try reinstalling confluence-cli.'));
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const destDir = path.resolve(options.dest);
|
|
183
|
+
const destFile = path.join(destDir, 'SKILL.md');
|
|
184
|
+
|
|
185
|
+
if (fs.existsSync(destFile) && !options.yes) {
|
|
186
|
+
const { confirmed } = await inquirer.prompt([
|
|
187
|
+
{
|
|
188
|
+
type: 'confirm',
|
|
189
|
+
name: 'confirmed',
|
|
190
|
+
default: true,
|
|
191
|
+
message: `Overwrite existing skill file at ${destFile}?`
|
|
192
|
+
}
|
|
193
|
+
]);
|
|
194
|
+
|
|
195
|
+
if (!confirmed) {
|
|
196
|
+
console.log(chalk.yellow('Cancelled.'));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
202
|
+
fs.copyFileSync(skillSrc, destFile);
|
|
203
|
+
|
|
204
|
+
console.log(chalk.green('✅ Skill installed successfully!'));
|
|
205
|
+
console.log(`Location: ${chalk.gray(destFile)}`);
|
|
206
|
+
console.log(chalk.yellow('Claude Code will now pick up confluence-cli knowledge from this file.'));
|
|
207
|
+
});
|
|
208
|
+
|
|
142
209
|
// Create command
|
|
143
210
|
program
|
|
144
|
-
.command('create <title>
|
|
211
|
+
.command('create <title> <spaceKey>')
|
|
145
212
|
.description('Create a new Confluence page')
|
|
146
213
|
.option('-f, --file <file>', 'Read content from file')
|
|
147
214
|
.option('-c, --content <content>', 'Page content as string')
|
|
148
215
|
.option('--format <format>', 'Content format (storage, html, markdown)', 'storage')
|
|
149
|
-
.option('--parent <parentId>', 'Parent page ID (creates page as child of this page)')
|
|
150
|
-
.option('--validate-storage', 'Validate storage content (XML well-formed) before sending')
|
|
151
|
-
.option('--no-sanitize-storage', 'Disable auto-escaping raw ampersands in storage format')
|
|
152
216
|
.action(async (title, spaceKey, options) => {
|
|
153
217
|
const analytics = new Analytics();
|
|
154
218
|
try {
|
|
155
|
-
const config = getConfig();
|
|
219
|
+
const config = getConfig(getProfileName());
|
|
220
|
+
assertWritable(config);
|
|
156
221
|
const client = new ConfluenceClient(config);
|
|
157
|
-
|
|
222
|
+
|
|
158
223
|
let content = '';
|
|
159
224
|
|
|
160
225
|
if (options.file) {
|
|
@@ -169,32 +234,13 @@ program
|
|
|
169
234
|
throw new Error('Either --file or --content option is required');
|
|
170
235
|
}
|
|
171
236
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
}
|
|
237
|
+
const result = await client.createPage(title, spaceKey, content, options.format);
|
|
238
|
+
|
|
239
|
+
console.log(chalk.green('✅ Page created successfully!'));
|
|
240
|
+
console.log(`Title: ${chalk.blue(result.title)}`);
|
|
241
|
+
console.log(`ID: ${chalk.blue(result.id)}`);
|
|
196
242
|
console.log(`Space: ${chalk.blue(result.space.name)} (${result.space.key})`);
|
|
197
|
-
console.log(`URL: ${chalk.gray(
|
|
243
|
+
console.log(`URL: ${chalk.gray(`${buildPageUrl(config, `/wiki${result._links.webui}`)}`)}`);
|
|
198
244
|
|
|
199
245
|
analytics.track('create', true);
|
|
200
246
|
} catch (error) {
|
|
@@ -211,16 +257,13 @@ program
|
|
|
211
257
|
.option('-f, --file <file>', 'Read content from file')
|
|
212
258
|
.option('-c, --content <content>', 'Page content as string')
|
|
213
259
|
.option('--format <format>', 'Content format (storage, html, markdown)', 'storage')
|
|
214
|
-
.option('--validate-storage', 'Validate storage content (XML well-formed) before sending')
|
|
215
|
-
.option('--no-sanitize-storage', 'Disable auto-escaping raw ampersands in storage format')
|
|
216
|
-
.option('--validate-storage', 'Validate storage content (XML well-formed) before sending')
|
|
217
|
-
.option('--no-sanitize-storage', 'Disable auto-escaping raw ampersands in storage format')
|
|
218
260
|
.action(async (title, parentId, options) => {
|
|
219
261
|
const analytics = new Analytics();
|
|
220
262
|
try {
|
|
221
|
-
const config = getConfig();
|
|
263
|
+
const config = getConfig(getProfileName());
|
|
264
|
+
assertWritable(config);
|
|
222
265
|
const client = new ConfluenceClient(config);
|
|
223
|
-
|
|
266
|
+
|
|
224
267
|
// Get parent page info to get space key
|
|
225
268
|
const parentInfo = await client.getPageInfo(parentId);
|
|
226
269
|
const spaceKey = parentInfo.space.key;
|
|
@@ -239,17 +282,14 @@ program
|
|
|
239
282
|
throw new Error('Either --file or --content option is required');
|
|
240
283
|
}
|
|
241
284
|
|
|
242
|
-
const result = await client.createChildPage(title, spaceKey, parentId, content, options.format
|
|
243
|
-
validateStorage: options.validateStorage,
|
|
244
|
-
sanitizeStorage: options.sanitizeStorage
|
|
245
|
-
});
|
|
285
|
+
const result = await client.createChildPage(title, spaceKey, parentId, content, options.format);
|
|
246
286
|
|
|
247
287
|
console.log(chalk.green('✅ Child page created successfully!'));
|
|
248
288
|
console.log(`Title: ${chalk.blue(result.title)}`);
|
|
249
289
|
console.log(`ID: ${chalk.blue(result.id)}`);
|
|
250
290
|
console.log(`Parent: ${chalk.blue(parentInfo.title)} (${parentId})`);
|
|
251
291
|
console.log(`Space: ${chalk.blue(result.space.name)} (${result.space.key})`);
|
|
252
|
-
console.log(`URL: ${chalk.gray(
|
|
292
|
+
console.log(`URL: ${chalk.gray(`${buildPageUrl(config, `/wiki${result._links.webui}`)}`)}`);
|
|
253
293
|
|
|
254
294
|
analytics.track('create_child', true);
|
|
255
295
|
} catch (error) {
|
|
@@ -267,8 +307,6 @@ program
|
|
|
267
307
|
.option('-f, --file <file>', 'Read content from file')
|
|
268
308
|
.option('-c, --content <content>', 'Page content as string')
|
|
269
309
|
.option('--format <format>', 'Content format (storage, html, markdown)', 'storage')
|
|
270
|
-
.option('--validate-storage', 'Validate storage content (XML well-formed) before sending')
|
|
271
|
-
.option('--no-sanitize-storage', 'Disable auto-escaping raw ampersands in storage format')
|
|
272
310
|
.action(async (pageId, options) => {
|
|
273
311
|
const analytics = new Analytics();
|
|
274
312
|
try {
|
|
@@ -277,9 +315,10 @@ program
|
|
|
277
315
|
throw new Error('At least one of --title, --file, or --content must be provided.');
|
|
278
316
|
}
|
|
279
317
|
|
|
280
|
-
const config = getConfig();
|
|
318
|
+
const config = getConfig(getProfileName());
|
|
319
|
+
assertWritable(config);
|
|
281
320
|
const client = new ConfluenceClient(config);
|
|
282
|
-
|
|
321
|
+
|
|
283
322
|
let content = null; // Use null to indicate no content change
|
|
284
323
|
|
|
285
324
|
if (options.file) {
|
|
@@ -292,16 +331,13 @@ program
|
|
|
292
331
|
content = options.content;
|
|
293
332
|
}
|
|
294
333
|
|
|
295
|
-
const result = await client.updatePage(pageId, options.title, content, options.format
|
|
296
|
-
validateStorage: options.validateStorage,
|
|
297
|
-
sanitizeStorage: options.sanitizeStorage
|
|
298
|
-
});
|
|
334
|
+
const result = await client.updatePage(pageId, options.title, content, options.format);
|
|
299
335
|
|
|
300
336
|
console.log(chalk.green('✅ Page updated successfully!'));
|
|
301
337
|
console.log(`Title: ${chalk.blue(result.title)}`);
|
|
302
338
|
console.log(`ID: ${chalk.blue(result.id)}`);
|
|
303
339
|
console.log(`Version: ${chalk.blue(result.version.number)}`);
|
|
304
|
-
console.log(`URL: ${chalk.gray(
|
|
340
|
+
console.log(`URL: ${chalk.gray(`${buildPageUrl(config, `/wiki${result._links.webui}`)}`)}`);
|
|
305
341
|
|
|
306
342
|
analytics.track('update', true);
|
|
307
343
|
} catch (error) {
|
|
@@ -311,6 +347,34 @@ program
|
|
|
311
347
|
}
|
|
312
348
|
});
|
|
313
349
|
|
|
350
|
+
// Move command
|
|
351
|
+
program
|
|
352
|
+
.command('move <pageId_or_url> <newParentId_or_url>')
|
|
353
|
+
.description('Move a page to a new parent location (within same space)')
|
|
354
|
+
.option('-t, --title <title>', 'New page title (optional)')
|
|
355
|
+
.action(async (pageId, newParentId, options) => {
|
|
356
|
+
const analytics = new Analytics();
|
|
357
|
+
try {
|
|
358
|
+
const config = getConfig(getProfileName());
|
|
359
|
+
assertWritable(config);
|
|
360
|
+
const client = new ConfluenceClient(config);
|
|
361
|
+
const result = await client.movePage(pageId, newParentId, options.title);
|
|
362
|
+
|
|
363
|
+
console.log(chalk.green('✅ Page moved successfully!'));
|
|
364
|
+
console.log(`Title: ${chalk.blue(result.title)}`);
|
|
365
|
+
console.log(`ID: ${chalk.blue(result.id)}`);
|
|
366
|
+
console.log(`New Parent: ${chalk.blue(newParentId)}`);
|
|
367
|
+
console.log(`Version: ${chalk.blue(result.version.number)}`);
|
|
368
|
+
console.log(`URL: ${chalk.gray(`${buildPageUrl(config, `/wiki${result._links.webui}`)}`)}`);
|
|
369
|
+
|
|
370
|
+
analytics.track('move', true);
|
|
371
|
+
} catch (error) {
|
|
372
|
+
analytics.track('move', false);
|
|
373
|
+
console.error(chalk.red('Error:'), error.message);
|
|
374
|
+
process.exit(1);
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
314
378
|
// Delete command
|
|
315
379
|
program
|
|
316
380
|
.command('delete <pageIdOrUrl>')
|
|
@@ -319,7 +383,8 @@ program
|
|
|
319
383
|
.action(async (pageIdOrUrl, options) => {
|
|
320
384
|
const analytics = new Analytics();
|
|
321
385
|
try {
|
|
322
|
-
const config = getConfig();
|
|
386
|
+
const config = getConfig(getProfileName());
|
|
387
|
+
assertWritable(config);
|
|
323
388
|
const client = new ConfluenceClient(config);
|
|
324
389
|
const pageInfo = await client.getPageInfo(pageIdOrUrl);
|
|
325
390
|
|
|
@@ -362,7 +427,8 @@ program
|
|
|
362
427
|
.action(async (pageId, options) => {
|
|
363
428
|
const analytics = new Analytics();
|
|
364
429
|
try {
|
|
365
|
-
const config = getConfig();
|
|
430
|
+
const config = getConfig(getProfileName());
|
|
431
|
+
assertWritable(config);
|
|
366
432
|
const client = new ConfluenceClient(config);
|
|
367
433
|
const pageData = await client.getPageForEdit(pageId);
|
|
368
434
|
|
|
@@ -399,7 +465,7 @@ program
|
|
|
399
465
|
.action(async (title, options) => {
|
|
400
466
|
const analytics = new Analytics();
|
|
401
467
|
try {
|
|
402
|
-
const config = getConfig();
|
|
468
|
+
const config = getConfig(getProfileName());
|
|
403
469
|
const client = new ConfluenceClient(config);
|
|
404
470
|
const pageInfo = await client.findPageByTitle(title, options.space);
|
|
405
471
|
|
|
@@ -407,7 +473,7 @@ program
|
|
|
407
473
|
console.log(`Title: ${chalk.green(pageInfo.title)}`);
|
|
408
474
|
console.log(`ID: ${chalk.green(pageInfo.id)}`);
|
|
409
475
|
console.log(`Space: ${chalk.green(pageInfo.space.name)} (${pageInfo.space.key})`);
|
|
410
|
-
console.log(`URL: ${chalk.gray(
|
|
476
|
+
console.log(`URL: ${chalk.gray(`${buildPageUrl(config, `/wiki${pageInfo.url}`)}`)}`);
|
|
411
477
|
|
|
412
478
|
analytics.track('find', true);
|
|
413
479
|
} catch (error) {
|
|
@@ -425,10 +491,11 @@ program
|
|
|
425
491
|
.option('-p, --pattern <glob>', 'Filter attachments by filename (e.g., "*.png")')
|
|
426
492
|
.option('-d, --download', 'Download matching attachments')
|
|
427
493
|
.option('--dest <directory>', 'Directory to save downloads (default: current directory)', '.')
|
|
494
|
+
.option('-f, --format <format>', 'Output format (text, json)', 'text')
|
|
428
495
|
.action(async (pageId, options) => {
|
|
429
496
|
const analytics = new Analytics();
|
|
430
497
|
try {
|
|
431
|
-
const config = getConfig();
|
|
498
|
+
const config = getConfig(getProfileName());
|
|
432
499
|
const client = new ConfluenceClient(config);
|
|
433
500
|
const maxResults = options.limit ? parseInt(options.limit, 10) : null;
|
|
434
501
|
const pattern = options.pattern ? options.pattern.trim() : null;
|
|
@@ -437,22 +504,47 @@ program
|
|
|
437
504
|
throw new Error('Limit must be a positive number.');
|
|
438
505
|
}
|
|
439
506
|
|
|
507
|
+
const format = (options.format || 'text').toLowerCase();
|
|
508
|
+
if (!['text', 'json'].includes(format)) {
|
|
509
|
+
throw new Error('Format must be one of: text, json');
|
|
510
|
+
}
|
|
511
|
+
|
|
440
512
|
const attachments = await client.getAllAttachments(pageId, { maxResults });
|
|
441
513
|
const filtered = pattern ? attachments.filter(att => client.matchesPattern(att.title, pattern)) : attachments;
|
|
442
514
|
|
|
443
515
|
if (filtered.length === 0) {
|
|
444
|
-
|
|
516
|
+
if (format === 'json') {
|
|
517
|
+
console.log(JSON.stringify({ attachmentCount: 0, attachments: [] }, null, 2));
|
|
518
|
+
} else {
|
|
519
|
+
console.log(chalk.yellow('No attachments found.'));
|
|
520
|
+
}
|
|
445
521
|
analytics.track('attachments', true);
|
|
446
522
|
return;
|
|
447
523
|
}
|
|
448
524
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
525
|
+
if (format === 'json' && !options.download) {
|
|
526
|
+
const output = {
|
|
527
|
+
attachmentCount: filtered.length,
|
|
528
|
+
attachments: filtered.map(att => ({
|
|
529
|
+
id: att.id,
|
|
530
|
+
title: att.title,
|
|
531
|
+
mediaType: att.mediaType || '',
|
|
532
|
+
fileSize: att.fileSize,
|
|
533
|
+
fileSizeFormatted: att.fileSize ? `${Math.max(1, Math.round(att.fileSize / 1024))} KB` : 'unknown size',
|
|
534
|
+
version: att.version,
|
|
535
|
+
downloadLink: att.downloadLink
|
|
536
|
+
}))
|
|
537
|
+
};
|
|
538
|
+
console.log(JSON.stringify(output, null, 2));
|
|
539
|
+
} else if (!options.download) {
|
|
540
|
+
console.log(chalk.blue(`Found ${filtered.length} attachment${filtered.length === 1 ? '' : 's'}:`));
|
|
541
|
+
filtered.forEach((att, index) => {
|
|
542
|
+
const sizeKb = att.fileSize ? `${Math.max(1, Math.round(att.fileSize / 1024))} KB` : 'unknown size';
|
|
543
|
+
const typeLabel = att.mediaType || 'unknown';
|
|
544
|
+
console.log(`${index + 1}. ${chalk.green(att.title)} (ID: ${att.id})`);
|
|
545
|
+
console.log(` Type: ${chalk.gray(typeLabel)} • Size: ${chalk.gray(sizeKb)} • Version: ${chalk.gray(att.version)}`);
|
|
546
|
+
});
|
|
547
|
+
}
|
|
456
548
|
|
|
457
549
|
if (options.download) {
|
|
458
550
|
const fs = require('fs');
|
|
@@ -481,17 +573,28 @@ program
|
|
|
481
573
|
writer.on('finish', resolve);
|
|
482
574
|
});
|
|
483
575
|
|
|
484
|
-
|
|
576
|
+
const downloadResults = [];
|
|
485
577
|
for (const attachment of filtered) {
|
|
486
578
|
const targetPath = uniquePathFor(destDir, attachment.title);
|
|
487
|
-
// Pass the full attachment object so downloadAttachment can use downloadLink directly
|
|
488
579
|
const dataStream = await client.downloadAttachment(pageId, attachment);
|
|
489
580
|
await writeStream(dataStream, targetPath);
|
|
490
|
-
|
|
491
|
-
|
|
581
|
+
downloadResults.push({ title: attachment.title, id: attachment.id, savedTo: targetPath });
|
|
582
|
+
if (format !== 'json') {
|
|
583
|
+
console.log(`⬇️ ${chalk.green(attachment.title)} -> ${chalk.gray(targetPath)}`);
|
|
584
|
+
}
|
|
492
585
|
}
|
|
493
586
|
|
|
494
|
-
|
|
587
|
+
if (format === 'json') {
|
|
588
|
+
const output = {
|
|
589
|
+
attachmentCount: filtered.length,
|
|
590
|
+
downloaded: downloadResults.length,
|
|
591
|
+
destination: destDir,
|
|
592
|
+
attachments: downloadResults
|
|
593
|
+
};
|
|
594
|
+
console.log(JSON.stringify(output, null, 2));
|
|
595
|
+
} else {
|
|
596
|
+
console.log(chalk.green(`Downloaded ${downloadResults.length} attachment${downloadResults.length === 1 ? '' : 's'} to ${destDir}`));
|
|
597
|
+
}
|
|
495
598
|
}
|
|
496
599
|
|
|
497
600
|
analytics.track('attachments', true);
|
|
@@ -502,6 +605,318 @@ program
|
|
|
502
605
|
}
|
|
503
606
|
});
|
|
504
607
|
|
|
608
|
+
// Attachment upload command
|
|
609
|
+
program
|
|
610
|
+
.command('attachment-upload <pageId>')
|
|
611
|
+
.description('Upload one or more attachments to a page')
|
|
612
|
+
.option('-f, --file <file>', 'File to upload (repeatable)', (value, previous) => {
|
|
613
|
+
const files = Array.isArray(previous) ? previous : [];
|
|
614
|
+
files.push(value);
|
|
615
|
+
return files;
|
|
616
|
+
}, [])
|
|
617
|
+
.option('--comment <comment>', 'Comment for the attachment(s)')
|
|
618
|
+
.option('--replace', 'Replace an existing attachment with the same filename')
|
|
619
|
+
.option('--minor-edit', 'Mark the upload as a minor edit')
|
|
620
|
+
.action(async (pageId, options) => {
|
|
621
|
+
const analytics = new Analytics();
|
|
622
|
+
try {
|
|
623
|
+
const files = Array.isArray(options.file) ? options.file.filter(Boolean) : [];
|
|
624
|
+
if (files.length === 0) {
|
|
625
|
+
throw new Error('At least one --file option is required.');
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const fs = require('fs');
|
|
629
|
+
const path = require('path');
|
|
630
|
+
const config = getConfig(getProfileName());
|
|
631
|
+
assertWritable(config);
|
|
632
|
+
const client = new ConfluenceClient(config);
|
|
633
|
+
|
|
634
|
+
const resolvedFiles = files.map((filePath) => ({
|
|
635
|
+
original: filePath,
|
|
636
|
+
resolved: path.resolve(filePath)
|
|
637
|
+
}));
|
|
638
|
+
|
|
639
|
+
resolvedFiles.forEach((file) => {
|
|
640
|
+
if (!fs.existsSync(file.resolved)) {
|
|
641
|
+
throw new Error(`File not found: ${file.original}`);
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
let uploaded = 0;
|
|
646
|
+
for (const file of resolvedFiles) {
|
|
647
|
+
const result = await client.uploadAttachment(pageId, file.resolved, {
|
|
648
|
+
comment: options.comment,
|
|
649
|
+
replace: options.replace,
|
|
650
|
+
minorEdit: options.minorEdit === true ? true : undefined
|
|
651
|
+
});
|
|
652
|
+
const attachment = result.results[0];
|
|
653
|
+
if (attachment) {
|
|
654
|
+
console.log(`⬆️ ${chalk.green(attachment.title)} (ID: ${attachment.id}, Version: ${attachment.version})`);
|
|
655
|
+
} else {
|
|
656
|
+
console.log(`⬆️ ${chalk.green(path.basename(file.resolved))}`);
|
|
657
|
+
}
|
|
658
|
+
uploaded += 1;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
console.log(chalk.green(`Uploaded ${uploaded} attachment${uploaded === 1 ? '' : 's'} to page ${pageId}`));
|
|
662
|
+
analytics.track('attachment_upload', true);
|
|
663
|
+
} catch (error) {
|
|
664
|
+
analytics.track('attachment_upload', false);
|
|
665
|
+
console.error(chalk.red('Error:'), error.message);
|
|
666
|
+
process.exit(1);
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
// Attachment delete command
|
|
671
|
+
program
|
|
672
|
+
.command('attachment-delete <pageId> <attachmentId>')
|
|
673
|
+
.description('Delete an attachment by ID from a page')
|
|
674
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
675
|
+
.action(async (pageId, attachmentId, options) => {
|
|
676
|
+
const analytics = new Analytics();
|
|
677
|
+
try {
|
|
678
|
+
const config = getConfig(getProfileName());
|
|
679
|
+
assertWritable(config);
|
|
680
|
+
const client = new ConfluenceClient(config);
|
|
681
|
+
|
|
682
|
+
if (!options.yes) {
|
|
683
|
+
const { confirmed } = await inquirer.prompt([
|
|
684
|
+
{
|
|
685
|
+
type: 'confirm',
|
|
686
|
+
name: 'confirmed',
|
|
687
|
+
default: false,
|
|
688
|
+
message: `Delete attachment ${attachmentId} from page ${pageId}?`
|
|
689
|
+
}
|
|
690
|
+
]);
|
|
691
|
+
|
|
692
|
+
if (!confirmed) {
|
|
693
|
+
console.log(chalk.yellow('Cancelled.'));
|
|
694
|
+
analytics.track('attachment_delete_cancel', true);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const result = await client.deleteAttachment(pageId, attachmentId);
|
|
700
|
+
|
|
701
|
+
console.log(chalk.green('✅ Attachment deleted successfully!'));
|
|
702
|
+
console.log(`ID: ${chalk.blue(result.id)}`);
|
|
703
|
+
console.log(`Page ID: ${chalk.blue(result.pageId)}`);
|
|
704
|
+
analytics.track('attachment_delete', true);
|
|
705
|
+
} catch (error) {
|
|
706
|
+
analytics.track('attachment_delete', false);
|
|
707
|
+
console.error(chalk.red('Error:'), error.message);
|
|
708
|
+
process.exit(1);
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
// Property list command
|
|
713
|
+
program
|
|
714
|
+
.command('property-list <pageId>')
|
|
715
|
+
.description('List all content properties for a page')
|
|
716
|
+
.option('-f, --format <format>', 'Output format (text, json)', 'text')
|
|
717
|
+
.option('-l, --limit <limit>', 'Maximum number of properties to fetch (default: 25)')
|
|
718
|
+
.option('--start <start>', 'Start index for results (default: 0)', '0')
|
|
719
|
+
.option('--all', 'Fetch all properties (ignores pagination)')
|
|
720
|
+
.action(async (pageId, options) => {
|
|
721
|
+
const analytics = new Analytics();
|
|
722
|
+
try {
|
|
723
|
+
const config = getConfig(getProfileName());
|
|
724
|
+
const client = new ConfluenceClient(config);
|
|
725
|
+
|
|
726
|
+
const format = (options.format || 'text').toLowerCase();
|
|
727
|
+
if (!['text', 'json'].includes(format)) {
|
|
728
|
+
throw new Error('Format must be one of: text, json');
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const limit = options.limit ? parseInt(options.limit, 10) : null;
|
|
732
|
+
if (options.limit && (Number.isNaN(limit) || limit <= 0)) {
|
|
733
|
+
throw new Error('Limit must be a positive number.');
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const start = options.start ? parseInt(options.start, 10) : 0;
|
|
737
|
+
if (options.start && (Number.isNaN(start) || start < 0)) {
|
|
738
|
+
throw new Error('Start must be a non-negative number.');
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
let properties = [];
|
|
742
|
+
let nextStart = null;
|
|
743
|
+
|
|
744
|
+
if (options.all) {
|
|
745
|
+
properties = await client.getAllProperties(pageId, {
|
|
746
|
+
maxResults: limit || null,
|
|
747
|
+
start
|
|
748
|
+
});
|
|
749
|
+
} else {
|
|
750
|
+
const response = await client.listProperties(pageId, {
|
|
751
|
+
limit: limit || undefined,
|
|
752
|
+
start
|
|
753
|
+
});
|
|
754
|
+
properties = response.results;
|
|
755
|
+
nextStart = response.nextStart;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
if (format === 'json') {
|
|
759
|
+
const output = { properties };
|
|
760
|
+
if (!options.all) {
|
|
761
|
+
output.nextStart = nextStart;
|
|
762
|
+
}
|
|
763
|
+
console.log(JSON.stringify(output, null, 2));
|
|
764
|
+
} else if (properties.length === 0) {
|
|
765
|
+
console.log(chalk.yellow('No properties found.'));
|
|
766
|
+
} else {
|
|
767
|
+
properties.forEach((prop, i) => {
|
|
768
|
+
const preview = JSON.stringify(prop.value);
|
|
769
|
+
const truncated = preview.length > 80 ? preview.slice(0, 77) + '...' : preview;
|
|
770
|
+
console.log(`${chalk.blue(i + 1 + '.')} ${chalk.green(prop.key)} (v${prop.version.number}): ${truncated}`);
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
if (!options.all && nextStart !== null && nextStart !== undefined) {
|
|
774
|
+
console.log(chalk.gray(`Next start: ${nextStart}`));
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
analytics.track('property_list', true);
|
|
778
|
+
} catch (error) {
|
|
779
|
+
analytics.track('property_list', false);
|
|
780
|
+
console.error(chalk.red('Error:'), error.message);
|
|
781
|
+
process.exit(1);
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
// Property get command
|
|
786
|
+
program
|
|
787
|
+
.command('property-get <pageId> <key>')
|
|
788
|
+
.description('Get a content property by key')
|
|
789
|
+
.option('-f, --format <format>', 'Output format (text, json)', 'text')
|
|
790
|
+
.action(async (pageId, key, options) => {
|
|
791
|
+
const analytics = new Analytics();
|
|
792
|
+
try {
|
|
793
|
+
const config = getConfig(getProfileName());
|
|
794
|
+
const client = new ConfluenceClient(config);
|
|
795
|
+
|
|
796
|
+
const format = (options.format || 'text').toLowerCase();
|
|
797
|
+
if (!['text', 'json'].includes(format)) {
|
|
798
|
+
throw new Error('Format must be one of: text, json');
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const property = await client.getProperty(pageId, key);
|
|
802
|
+
|
|
803
|
+
if (format === 'json') {
|
|
804
|
+
console.log(JSON.stringify(property, null, 2));
|
|
805
|
+
} else {
|
|
806
|
+
console.log(`${chalk.green('Key:')} ${property.key}`);
|
|
807
|
+
console.log(`${chalk.green('Version:')} ${property.version.number}`);
|
|
808
|
+
console.log(`${chalk.green('Value:')}`);
|
|
809
|
+
console.log(JSON.stringify(property.value, null, 2));
|
|
810
|
+
}
|
|
811
|
+
analytics.track('property_get', true);
|
|
812
|
+
} catch (error) {
|
|
813
|
+
analytics.track('property_get', false);
|
|
814
|
+
console.error(chalk.red('Error:'), error.message);
|
|
815
|
+
process.exit(1);
|
|
816
|
+
}
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
// Property set command
|
|
820
|
+
program
|
|
821
|
+
.command('property-set <pageId> <key>')
|
|
822
|
+
.description('Set a content property (create or update)')
|
|
823
|
+
.option('-v, --value <json>', 'Property value as JSON')
|
|
824
|
+
.option('--file <file>', 'Read property value from a JSON file')
|
|
825
|
+
.option('-f, --format <format>', 'Output format (text, json)', 'text')
|
|
826
|
+
.action(async (pageId, key, options) => {
|
|
827
|
+
const analytics = new Analytics();
|
|
828
|
+
try {
|
|
829
|
+
const config = getConfig(getProfileName());
|
|
830
|
+
assertWritable(config);
|
|
831
|
+
const client = new ConfluenceClient(config);
|
|
832
|
+
|
|
833
|
+
if (!options.value && !options.file) {
|
|
834
|
+
throw new Error('Provide a value with --value or --file.');
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
let value;
|
|
838
|
+
if (options.file) {
|
|
839
|
+
const fs = require('fs');
|
|
840
|
+
const raw = fs.readFileSync(options.file, 'utf-8');
|
|
841
|
+
try {
|
|
842
|
+
value = JSON.parse(raw);
|
|
843
|
+
} catch {
|
|
844
|
+
throw new Error(`Invalid JSON in file ${options.file}`);
|
|
845
|
+
}
|
|
846
|
+
} else {
|
|
847
|
+
try {
|
|
848
|
+
value = JSON.parse(options.value);
|
|
849
|
+
} catch {
|
|
850
|
+
throw new Error('Invalid JSON in --value');
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
const format = (options.format || 'text').toLowerCase();
|
|
855
|
+
if (!['text', 'json'].includes(format)) {
|
|
856
|
+
throw new Error('Format must be one of: text, json');
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const result = await client.setProperty(pageId, key, value);
|
|
860
|
+
|
|
861
|
+
if (format === 'json') {
|
|
862
|
+
console.log(JSON.stringify(result, null, 2));
|
|
863
|
+
} else {
|
|
864
|
+
console.log(chalk.green('✅ Property set successfully!'));
|
|
865
|
+
console.log(`${chalk.green('Key:')} ${result.key}`);
|
|
866
|
+
console.log(`${chalk.green('Version:')} ${result.version.number}`);
|
|
867
|
+
console.log(`${chalk.green('Value:')}`);
|
|
868
|
+
console.log(JSON.stringify(result.value, null, 2));
|
|
869
|
+
}
|
|
870
|
+
analytics.track('property_set', true);
|
|
871
|
+
} catch (error) {
|
|
872
|
+
analytics.track('property_set', false);
|
|
873
|
+
console.error(chalk.red('Error:'), error.message);
|
|
874
|
+
process.exit(1);
|
|
875
|
+
}
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
// Property delete command
|
|
879
|
+
program
|
|
880
|
+
.command('property-delete <pageId> <key>')
|
|
881
|
+
.description('Delete a content property by key')
|
|
882
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
883
|
+
.action(async (pageId, key, options) => {
|
|
884
|
+
const analytics = new Analytics();
|
|
885
|
+
try {
|
|
886
|
+
const config = getConfig(getProfileName());
|
|
887
|
+
assertWritable(config);
|
|
888
|
+
const client = new ConfluenceClient(config);
|
|
889
|
+
|
|
890
|
+
if (!options.yes) {
|
|
891
|
+
const { confirmed } = await inquirer.prompt([
|
|
892
|
+
{
|
|
893
|
+
type: 'confirm',
|
|
894
|
+
name: 'confirmed',
|
|
895
|
+
default: false,
|
|
896
|
+
message: `Delete property "${key}" from page ${pageId}?`
|
|
897
|
+
}
|
|
898
|
+
]);
|
|
899
|
+
|
|
900
|
+
if (!confirmed) {
|
|
901
|
+
console.log(chalk.yellow('Cancelled.'));
|
|
902
|
+
analytics.track('property_delete_cancel', true);
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
const result = await client.deleteProperty(pageId, key);
|
|
908
|
+
|
|
909
|
+
console.log(chalk.green('✅ Property deleted successfully!'));
|
|
910
|
+
console.log(`${chalk.green('Key:')} ${chalk.blue(result.key)}`);
|
|
911
|
+
console.log(`${chalk.green('Page ID:')} ${chalk.blue(result.pageId)}`);
|
|
912
|
+
analytics.track('property_delete', true);
|
|
913
|
+
} catch (error) {
|
|
914
|
+
analytics.track('property_delete', false);
|
|
915
|
+
console.error(chalk.red('Error:'), error.message);
|
|
916
|
+
process.exit(1);
|
|
917
|
+
}
|
|
918
|
+
});
|
|
919
|
+
|
|
505
920
|
// Comments command
|
|
506
921
|
program
|
|
507
922
|
.command('comments <pageId>')
|
|
@@ -515,7 +930,7 @@ program
|
|
|
515
930
|
.action(async (pageId, options) => {
|
|
516
931
|
const analytics = new Analytics();
|
|
517
932
|
try {
|
|
518
|
-
const config = getConfig();
|
|
933
|
+
const config = getConfig(getProfileName());
|
|
519
934
|
const client = new ConfluenceClient(config);
|
|
520
935
|
|
|
521
936
|
const format = (options.format || 'text').toLowerCase();
|
|
@@ -670,7 +1085,8 @@ program
|
|
|
670
1085
|
const analytics = new Analytics();
|
|
671
1086
|
let location = null;
|
|
672
1087
|
try {
|
|
673
|
-
const config = getConfig();
|
|
1088
|
+
const config = getConfig(getProfileName());
|
|
1089
|
+
assertWritable(config);
|
|
674
1090
|
const client = new ConfluenceClient(config);
|
|
675
1091
|
|
|
676
1092
|
let content = '';
|
|
@@ -784,7 +1200,8 @@ program
|
|
|
784
1200
|
.action(async (commentId, options) => {
|
|
785
1201
|
const analytics = new Analytics();
|
|
786
1202
|
try {
|
|
787
|
-
const config = getConfig();
|
|
1203
|
+
const config = getConfig(getProfileName());
|
|
1204
|
+
assertWritable(config);
|
|
788
1205
|
const client = new ConfluenceClient(config);
|
|
789
1206
|
|
|
790
1207
|
if (!options.yes) {
|
|
@@ -827,14 +1244,26 @@ program
|
|
|
827
1244
|
.option('--pattern <glob>', 'Filter attachments by filename (e.g., "*.png")')
|
|
828
1245
|
.option('--referenced-only', 'Download only attachments referenced in the page content')
|
|
829
1246
|
.option('--skip-attachments', 'Do not download attachments')
|
|
1247
|
+
.option('-r, --recursive', 'Export page and all descendants')
|
|
1248
|
+
.option('--max-depth <depth>', 'Limit recursion depth (default: 10)', parseInt)
|
|
1249
|
+
.option('--exclude <patterns>', 'Comma-separated title glob patterns to skip')
|
|
1250
|
+
.option('--delay-ms <ms>', 'Delay between page exports in ms (default: 100)', parseInt)
|
|
1251
|
+
.option('--dry-run', 'Preview pages without writing files')
|
|
1252
|
+
.option('--overwrite', 'Overwrite existing export directory (replaces content, removes stale files)')
|
|
830
1253
|
.action(async (pageId, options) => {
|
|
831
1254
|
const analytics = new Analytics();
|
|
832
1255
|
try {
|
|
833
|
-
const config = getConfig();
|
|
1256
|
+
const config = getConfig(getProfileName());
|
|
834
1257
|
const client = new ConfluenceClient(config);
|
|
835
1258
|
const fs = require('fs');
|
|
836
1259
|
const path = require('path');
|
|
837
1260
|
|
|
1261
|
+
if (options.recursive) {
|
|
1262
|
+
await exportRecursive(client, fs, path, pageId, options);
|
|
1263
|
+
analytics.track('export', true);
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
838
1267
|
const format = (options.format || 'markdown').toLowerCase();
|
|
839
1268
|
const formatExt = { markdown: 'md', html: 'html', text: 'txt' };
|
|
840
1269
|
const contentExt = formatExt[format] || 'txt';
|
|
@@ -852,11 +1281,18 @@ program
|
|
|
852
1281
|
const baseDir = path.resolve(options.dest || '.');
|
|
853
1282
|
const folderName = sanitizeTitle(pageInfo.title || 'page');
|
|
854
1283
|
const exportDir = path.join(baseDir, folderName);
|
|
1284
|
+
if (options.overwrite && fs.existsSync(exportDir)) {
|
|
1285
|
+
if (!isExportDirectory(fs, path, exportDir)) {
|
|
1286
|
+
throw new Error(`Refusing to overwrite "${exportDir}" - it was not created by confluence-cli (missing ${EXPORT_MARKER}).`);
|
|
1287
|
+
}
|
|
1288
|
+
fs.rmSync(exportDir, { recursive: true, force: true });
|
|
1289
|
+
}
|
|
855
1290
|
fs.mkdirSync(exportDir, { recursive: true });
|
|
856
1291
|
|
|
857
1292
|
const contentFile = options.file || `page.${contentExt}`;
|
|
858
1293
|
const contentPath = path.join(exportDir, contentFile);
|
|
859
1294
|
fs.writeFileSync(contentPath, content);
|
|
1295
|
+
writeExportMarker(fs, path, exportDir, { pageId, title: pageInfo.title });
|
|
860
1296
|
|
|
861
1297
|
console.log(chalk.green('✅ Page exported'));
|
|
862
1298
|
console.log(`Title: ${chalk.blue(pageInfo.title)}`);
|
|
@@ -882,33 +1318,12 @@ program
|
|
|
882
1318
|
const attachmentsDir = path.join(exportDir, attachmentsDirName);
|
|
883
1319
|
fs.mkdirSync(attachmentsDir, { recursive: true });
|
|
884
1320
|
|
|
885
|
-
const uniquePathFor = (dir, filename) => {
|
|
886
|
-
const parsed = path.parse(filename);
|
|
887
|
-
let attempt = path.join(dir, filename);
|
|
888
|
-
let counter = 1;
|
|
889
|
-
while (fs.existsSync(attempt)) {
|
|
890
|
-
const suffix = ` (${counter})`;
|
|
891
|
-
const nextName = `${parsed.name}${suffix}${parsed.ext}`;
|
|
892
|
-
attempt = path.join(dir, nextName);
|
|
893
|
-
counter += 1;
|
|
894
|
-
}
|
|
895
|
-
return attempt;
|
|
896
|
-
};
|
|
897
|
-
|
|
898
|
-
const writeStream = (stream, targetPath) => new Promise((resolve, reject) => {
|
|
899
|
-
const writer = fs.createWriteStream(targetPath);
|
|
900
|
-
stream.pipe(writer);
|
|
901
|
-
stream.on('error', reject);
|
|
902
|
-
writer.on('error', reject);
|
|
903
|
-
writer.on('finish', resolve);
|
|
904
|
-
});
|
|
905
|
-
|
|
906
1321
|
let downloaded = 0;
|
|
907
1322
|
for (const attachment of filtered) {
|
|
908
|
-
const targetPath = uniquePathFor(attachmentsDir, attachment.title);
|
|
1323
|
+
const targetPath = uniquePathFor(fs, path, attachmentsDir, attachment.title);
|
|
909
1324
|
// Pass the full attachment object so downloadAttachment can use downloadLink directly
|
|
910
1325
|
const dataStream = await client.downloadAttachment(pageId, attachment);
|
|
911
|
-
await writeStream(dataStream, targetPath);
|
|
1326
|
+
await writeStream(fs, dataStream, targetPath);
|
|
912
1327
|
downloaded += 1;
|
|
913
1328
|
console.log(`⬇️ ${chalk.green(attachment.title)} -> ${chalk.gray(targetPath)}`);
|
|
914
1329
|
}
|
|
@@ -925,6 +1340,219 @@ program
|
|
|
925
1340
|
}
|
|
926
1341
|
});
|
|
927
1342
|
|
|
1343
|
+
const EXPORT_MARKER = '.confluence-export.json';
|
|
1344
|
+
|
|
1345
|
+
function writeExportMarker(fs, path, exportDir, meta) {
|
|
1346
|
+
const marker = {
|
|
1347
|
+
exportedAt: new Date().toISOString(),
|
|
1348
|
+
pageId: meta.pageId,
|
|
1349
|
+
title: meta.title,
|
|
1350
|
+
tool: 'confluence-cli',
|
|
1351
|
+
};
|
|
1352
|
+
fs.writeFileSync(path.join(exportDir, EXPORT_MARKER), JSON.stringify(marker, null, 2));
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
function isExportDirectory(fs, path, dir) {
|
|
1356
|
+
return fs.existsSync(path.join(dir, EXPORT_MARKER));
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
function uniquePathFor(fs, path, dir, filename) {
|
|
1360
|
+
const parsed = path.parse(filename);
|
|
1361
|
+
let attempt = path.join(dir, filename);
|
|
1362
|
+
let counter = 1;
|
|
1363
|
+
while (fs.existsSync(attempt)) {
|
|
1364
|
+
const suffix = ` (${counter})`;
|
|
1365
|
+
const nextName = `${parsed.name}${suffix}${parsed.ext}`;
|
|
1366
|
+
attempt = path.join(dir, nextName);
|
|
1367
|
+
counter += 1;
|
|
1368
|
+
}
|
|
1369
|
+
return attempt;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
function writeStream(fs, stream, targetPath) {
|
|
1373
|
+
return new Promise((resolve, reject) => {
|
|
1374
|
+
const writer = fs.createWriteStream(targetPath);
|
|
1375
|
+
stream.pipe(writer);
|
|
1376
|
+
stream.on('error', reject);
|
|
1377
|
+
writer.on('error', reject);
|
|
1378
|
+
writer.on('finish', resolve);
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
async function exportRecursive(client, fs, path, pageId, options) {
|
|
1383
|
+
const maxDepth = options.maxDepth || 10;
|
|
1384
|
+
const delayMs = options.delayMs != null ? options.delayMs : 100;
|
|
1385
|
+
const excludePatterns = options.exclude
|
|
1386
|
+
? options.exclude.split(',').map(p => p.trim()).filter(Boolean)
|
|
1387
|
+
: [];
|
|
1388
|
+
const format = (options.format || 'markdown').toLowerCase();
|
|
1389
|
+
const formatExt = { markdown: 'md', html: 'html', text: 'txt' };
|
|
1390
|
+
const contentExt = formatExt[format] || 'txt';
|
|
1391
|
+
const contentFile = options.file || `page.${contentExt}`;
|
|
1392
|
+
const baseDir = path.resolve(options.dest || '.');
|
|
1393
|
+
|
|
1394
|
+
// 1. Fetch root page
|
|
1395
|
+
const rootPage = await client.getPageInfo(pageId);
|
|
1396
|
+
console.log(`Fetching descendants of "${chalk.blue(rootPage.title)}"...`);
|
|
1397
|
+
|
|
1398
|
+
// 2. Fetch all descendants
|
|
1399
|
+
const descendants = await client.getAllDescendantPages(pageId, maxDepth);
|
|
1400
|
+
|
|
1401
|
+
// 3. Filter by exclude patterns
|
|
1402
|
+
const allPages = [{ id: rootPage.id, title: rootPage.title, parentId: null }];
|
|
1403
|
+
for (const page of descendants) {
|
|
1404
|
+
if (excludePatterns.length && client.shouldExcludePage(page.title, excludePatterns)) {
|
|
1405
|
+
continue;
|
|
1406
|
+
}
|
|
1407
|
+
allPages.push(page);
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// 4. Build tree
|
|
1411
|
+
const tree = client.buildPageTree(allPages.slice(1), pageId);
|
|
1412
|
+
|
|
1413
|
+
const totalPages = allPages.length;
|
|
1414
|
+
console.log(`Found ${chalk.blue(totalPages)} page${totalPages === 1 ? '' : 's'} to export.`);
|
|
1415
|
+
|
|
1416
|
+
// 5. Dry run — print tree and return
|
|
1417
|
+
if (options.dryRun) {
|
|
1418
|
+
const printTree = (nodes, indent = '') => {
|
|
1419
|
+
for (const node of nodes) {
|
|
1420
|
+
console.log(`${indent}${chalk.blue(node.title)} (${node.id})`);
|
|
1421
|
+
if (node.children && node.children.length) {
|
|
1422
|
+
printTree(node.children, indent + ' ');
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
};
|
|
1426
|
+
console.log(`\n${chalk.blue(rootPage.title)} (${rootPage.id})`);
|
|
1427
|
+
printTree(tree, ' ');
|
|
1428
|
+
console.log(chalk.yellow('\nDry run — no files written.'));
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
// 6. Overwrite — remove existing root export directory for a clean slate
|
|
1433
|
+
if (options.overwrite) {
|
|
1434
|
+
const rootFolderName = sanitizeTitle(rootPage.title);
|
|
1435
|
+
const rootExportDir = path.join(baseDir, rootFolderName);
|
|
1436
|
+
if (fs.existsSync(rootExportDir)) {
|
|
1437
|
+
if (!isExportDirectory(fs, path, rootExportDir)) {
|
|
1438
|
+
throw new Error(`Refusing to overwrite "${rootExportDir}" - it was not created by confluence-cli (missing ${EXPORT_MARKER}).`);
|
|
1439
|
+
}
|
|
1440
|
+
fs.rmSync(rootExportDir, { recursive: true, force: true });
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
// 7. Walk tree depth-first and export each page
|
|
1445
|
+
const failures = [];
|
|
1446
|
+
let exported = 0;
|
|
1447
|
+
|
|
1448
|
+
async function exportPage(page, dir) {
|
|
1449
|
+
exported += 1;
|
|
1450
|
+
console.log(`[${exported}/${totalPages}] Exporting: ${chalk.blue(page.title)}`);
|
|
1451
|
+
|
|
1452
|
+
const folderName = sanitizeTitle(page.title);
|
|
1453
|
+
let exportDir = path.join(dir, folderName);
|
|
1454
|
+
|
|
1455
|
+
// Handle duplicate sibling folder names
|
|
1456
|
+
if (fs.existsSync(exportDir)) {
|
|
1457
|
+
let counter = 1;
|
|
1458
|
+
while (fs.existsSync(`${exportDir} (${counter})`)) {
|
|
1459
|
+
counter += 1;
|
|
1460
|
+
}
|
|
1461
|
+
exportDir = `${exportDir} (${counter})`;
|
|
1462
|
+
}
|
|
1463
|
+
fs.mkdirSync(exportDir, { recursive: true });
|
|
1464
|
+
|
|
1465
|
+
// Fetch content and write
|
|
1466
|
+
const content = await client.readPage(
|
|
1467
|
+
page.id,
|
|
1468
|
+
format,
|
|
1469
|
+
options.referencedOnly ? { extractReferencedAttachments: true } : {}
|
|
1470
|
+
);
|
|
1471
|
+
const referencedAttachments = options.referencedOnly
|
|
1472
|
+
? (client._referencedAttachments || new Set())
|
|
1473
|
+
: null;
|
|
1474
|
+
fs.writeFileSync(path.join(exportDir, contentFile), content);
|
|
1475
|
+
|
|
1476
|
+
// Download attachments
|
|
1477
|
+
if (!options.skipAttachments) {
|
|
1478
|
+
const pattern = options.pattern ? options.pattern.trim() : null;
|
|
1479
|
+
const allAttachments = await client.getAllAttachments(page.id);
|
|
1480
|
+
|
|
1481
|
+
let filtered;
|
|
1482
|
+
if (pattern) {
|
|
1483
|
+
filtered = allAttachments.filter(att => client.matchesPattern(att.title, pattern));
|
|
1484
|
+
} else if (options.referencedOnly) {
|
|
1485
|
+
filtered = allAttachments.filter(att => referencedAttachments?.has(att.title));
|
|
1486
|
+
} else {
|
|
1487
|
+
filtered = allAttachments;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
if (filtered.length > 0) {
|
|
1491
|
+
const attachmentsDirName = options.attachmentsDir || 'attachments';
|
|
1492
|
+
const attachmentsDir = path.join(exportDir, attachmentsDirName);
|
|
1493
|
+
fs.mkdirSync(attachmentsDir, { recursive: true });
|
|
1494
|
+
|
|
1495
|
+
for (const attachment of filtered) {
|
|
1496
|
+
const targetPath = uniquePathFor(fs, path, attachmentsDir, attachment.title);
|
|
1497
|
+
const dataStream = await client.downloadAttachment(page.id, attachment);
|
|
1498
|
+
await writeStream(fs, dataStream, targetPath);
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
return exportDir;
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
async function walkTree(nodes, parentDir) {
|
|
1507
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
1508
|
+
const node = nodes[i];
|
|
1509
|
+
try {
|
|
1510
|
+
const nodeDir = await exportPage(node, parentDir);
|
|
1511
|
+
if (node.children && node.children.length) {
|
|
1512
|
+
await walkTree(node.children, nodeDir);
|
|
1513
|
+
}
|
|
1514
|
+
} catch (error) {
|
|
1515
|
+
failures.push({ id: node.id, title: node.title, error: error.message });
|
|
1516
|
+
console.error(chalk.red(` Failed: ${node.title} — ${error.message}`));
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
// Rate limiting between pages
|
|
1520
|
+
if (delayMs > 0 && exported < totalPages) {
|
|
1521
|
+
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
// Export root page
|
|
1527
|
+
let rootDir;
|
|
1528
|
+
try {
|
|
1529
|
+
rootDir = await exportPage(rootPage, baseDir);
|
|
1530
|
+
writeExportMarker(fs, path, rootDir, { pageId, title: rootPage.title });
|
|
1531
|
+
} catch (error) {
|
|
1532
|
+
failures.push({ id: rootPage.id, title: rootPage.title, error: error.message });
|
|
1533
|
+
console.error(chalk.red(` Failed: ${rootPage.title} — ${error.message}`));
|
|
1534
|
+
// Can't continue without root directory
|
|
1535
|
+
throw new Error(`Failed to export root page: ${error.message}`);
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
if (delayMs > 0 && tree.length > 0) {
|
|
1539
|
+
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
// Export descendants
|
|
1543
|
+
await walkTree(tree, rootDir);
|
|
1544
|
+
|
|
1545
|
+
// 8. Summary
|
|
1546
|
+
const succeeded = exported - failures.length;
|
|
1547
|
+
console.log(chalk.green(`\n✅ Exported ${succeeded}/${totalPages} page${totalPages === 1 ? '' : 's'} to ${rootDir}`));
|
|
1548
|
+
if (failures.length > 0) {
|
|
1549
|
+
console.log(chalk.red(`\n${failures.length} failure${failures.length === 1 ? '' : 's'}:`));
|
|
1550
|
+
for (const f of failures) {
|
|
1551
|
+
console.log(chalk.red(` - ${f.title} (${f.id}): ${f.error}`));
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
|
|
928
1556
|
function sanitizeTitle(value) {
|
|
929
1557
|
const fallback = 'page';
|
|
930
1558
|
if (!value || typeof value !== 'string') {
|
|
@@ -991,9 +1619,10 @@ program
|
|
|
991
1619
|
.action(async (sourcePageId, targetParentId, newTitle, options) => {
|
|
992
1620
|
const analytics = new Analytics();
|
|
993
1621
|
try {
|
|
994
|
-
const config = getConfig();
|
|
1622
|
+
const config = getConfig(getProfileName());
|
|
1623
|
+
assertWritable(config);
|
|
995
1624
|
const client = new ConfluenceClient(config);
|
|
996
|
-
|
|
1625
|
+
|
|
997
1626
|
// Parse numeric flags with safe fallbacks
|
|
998
1627
|
const parsedDepth = parseInt(options.maxDepth, 10);
|
|
999
1628
|
const maxDepth = Number.isNaN(parsedDepth) ? 10 : parsedDepth;
|
|
@@ -1084,7 +1713,7 @@ program
|
|
|
1084
1713
|
console.log(` - ...and ${result.failures.length - 10} more`);
|
|
1085
1714
|
}
|
|
1086
1715
|
}
|
|
1087
|
-
console.log(`URL: ${chalk.gray(
|
|
1716
|
+
console.log(`URL: ${chalk.gray(`${buildPageUrl(config, `/wiki${result.rootPage._links.webui}`)}`)}`);
|
|
1088
1717
|
if (options.failOnError && result.failures?.length) {
|
|
1089
1718
|
analytics.track('copy_tree', false);
|
|
1090
1719
|
console.error(chalk.red('Completed with failures and --fail-on-error is set.'));
|
|
@@ -1111,7 +1740,7 @@ program
|
|
|
1111
1740
|
.action(async (pageId, options) => {
|
|
1112
1741
|
const analytics = new Analytics();
|
|
1113
1742
|
try {
|
|
1114
|
-
const config = getConfig();
|
|
1743
|
+
const config = getConfig(getProfileName());
|
|
1115
1744
|
const client = new ConfluenceClient(config);
|
|
1116
1745
|
|
|
1117
1746
|
// Extract page ID from URL if needed
|
|
@@ -1146,7 +1775,7 @@ program
|
|
|
1146
1775
|
type: page.type,
|
|
1147
1776
|
status: page.status,
|
|
1148
1777
|
spaceKey: page.space?.key,
|
|
1149
|
-
url:
|
|
1778
|
+
url: `${buildPageUrl(config, `/wiki/spaces/${page.space?.key}/pages/${page.id}`)}`,
|
|
1150
1779
|
parentId: page.parentId || resolvedPageId
|
|
1151
1780
|
}))
|
|
1152
1781
|
};
|
|
@@ -1175,7 +1804,7 @@ program
|
|
|
1175
1804
|
}
|
|
1176
1805
|
|
|
1177
1806
|
if (options.showUrl) {
|
|
1178
|
-
const url =
|
|
1807
|
+
const url = `${buildPageUrl(config, `/wiki/spaces/${page.space?.key}/pages/${page.id}`)}`;
|
|
1179
1808
|
output += `\n ${chalk.gray(url)}`;
|
|
1180
1809
|
}
|
|
1181
1810
|
|
|
@@ -1240,7 +1869,7 @@ function printTree(nodes, config, options, depth = 1) {
|
|
|
1240
1869
|
}
|
|
1241
1870
|
|
|
1242
1871
|
if (options.showUrl) {
|
|
1243
|
-
const url =
|
|
1872
|
+
const url = `${buildPageUrl(config, `/wiki/spaces/${node.space?.key}/pages/${node.id}`)}`;
|
|
1244
1873
|
output += `\n${indent}${isLast ? ' ' : '│ '}${chalk.gray(url)}`;
|
|
1245
1874
|
}
|
|
1246
1875
|
|
|
@@ -1252,8 +1881,99 @@ function printTree(nodes, config, options, depth = 1) {
|
|
|
1252
1881
|
});
|
|
1253
1882
|
}
|
|
1254
1883
|
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1884
|
+
// Profile management commands
|
|
1885
|
+
const profileCmd = program
|
|
1886
|
+
.command('profile')
|
|
1887
|
+
.description('Manage configuration profiles');
|
|
1888
|
+
|
|
1889
|
+
profileCmd
|
|
1890
|
+
.command('list')
|
|
1891
|
+
.description('List all configuration profiles')
|
|
1892
|
+
.action(() => {
|
|
1893
|
+
const { profiles } = listProfiles();
|
|
1894
|
+
if (profiles.length === 0) {
|
|
1895
|
+
console.log(chalk.yellow('No profiles configured. Run "confluence init" to create one.'));
|
|
1896
|
+
return;
|
|
1897
|
+
}
|
|
1898
|
+
console.log(chalk.blue('Configuration profiles:\n'));
|
|
1899
|
+
profiles.forEach(p => {
|
|
1900
|
+
const marker = p.active ? chalk.green(' (active)') : '';
|
|
1901
|
+
const readOnlyBadge = p.readOnly ? chalk.red(' [read-only]') : '';
|
|
1902
|
+
console.log(` ${p.active ? chalk.green('*') : ' '} ${chalk.cyan(p.name)}${marker}${readOnlyBadge} - ${chalk.gray(p.domain)}`);
|
|
1903
|
+
});
|
|
1904
|
+
});
|
|
1258
1905
|
|
|
1259
|
-
|
|
1906
|
+
profileCmd
|
|
1907
|
+
.command('use <name>')
|
|
1908
|
+
.description('Set the active configuration profile')
|
|
1909
|
+
.action((name) => {
|
|
1910
|
+
try {
|
|
1911
|
+
setActiveProfile(name);
|
|
1912
|
+
console.log(chalk.green(`Switched to profile "${name}"`));
|
|
1913
|
+
} catch (error) {
|
|
1914
|
+
console.error(chalk.red('Error:'), error.message);
|
|
1915
|
+
process.exit(1);
|
|
1916
|
+
}
|
|
1917
|
+
});
|
|
1918
|
+
|
|
1919
|
+
profileCmd
|
|
1920
|
+
.command('add <name>')
|
|
1921
|
+
.description('Add a new configuration profile interactively')
|
|
1922
|
+
.option('-d, --domain <domain>', 'Confluence domain')
|
|
1923
|
+
.option('--protocol <protocol>', 'Protocol (http or https)')
|
|
1924
|
+
.option('-p, --api-path <path>', 'REST API path')
|
|
1925
|
+
.option('-a, --auth-type <type>', 'Authentication type (basic or bearer)')
|
|
1926
|
+
.option('-e, --email <email>', 'Email or username for basic auth')
|
|
1927
|
+
.option('-t, --token <token>', 'API token')
|
|
1928
|
+
.option('--read-only', 'Set profile to read-only mode (blocks write operations)')
|
|
1929
|
+
.action(async (name, options) => {
|
|
1930
|
+
if (!isValidProfileName(name)) {
|
|
1931
|
+
console.error(chalk.red('Invalid profile name. Use only letters, numbers, hyphens, and underscores.'));
|
|
1932
|
+
process.exit(1);
|
|
1933
|
+
}
|
|
1934
|
+
await initConfig({ ...options, profile: name });
|
|
1935
|
+
});
|
|
1936
|
+
|
|
1937
|
+
profileCmd
|
|
1938
|
+
.command('remove <name>')
|
|
1939
|
+
.description('Remove a configuration profile')
|
|
1940
|
+
.action(async (name) => {
|
|
1941
|
+
try {
|
|
1942
|
+
const { confirmed } = await inquirer.prompt([{
|
|
1943
|
+
type: 'confirm',
|
|
1944
|
+
name: 'confirmed',
|
|
1945
|
+
message: `Delete profile "${name}"?`,
|
|
1946
|
+
default: false
|
|
1947
|
+
}]);
|
|
1948
|
+
if (!confirmed) {
|
|
1949
|
+
console.log(chalk.yellow('Cancelled.'));
|
|
1950
|
+
return;
|
|
1951
|
+
}
|
|
1952
|
+
deleteProfile(name);
|
|
1953
|
+
console.log(chalk.green(`Profile "${name}" removed.`));
|
|
1954
|
+
} catch (error) {
|
|
1955
|
+
console.error(chalk.red('Error:'), error.message);
|
|
1956
|
+
process.exit(1);
|
|
1957
|
+
}
|
|
1958
|
+
});
|
|
1959
|
+
|
|
1960
|
+
// Exported for testing
|
|
1961
|
+
module.exports = {
|
|
1962
|
+
program,
|
|
1963
|
+
_test: {
|
|
1964
|
+
EXPORT_MARKER,
|
|
1965
|
+
writeExportMarker,
|
|
1966
|
+
isExportDirectory,
|
|
1967
|
+
uniquePathFor,
|
|
1968
|
+
exportRecursive,
|
|
1969
|
+
sanitizeTitle,
|
|
1970
|
+
assertWritable,
|
|
1971
|
+
},
|
|
1972
|
+
};
|
|
1973
|
+
|
|
1974
|
+
if (require.main === module) {
|
|
1975
|
+
if (process.argv.length <= 2) {
|
|
1976
|
+
program.help({ error: false });
|
|
1977
|
+
}
|
|
1978
|
+
program.parse(process.argv);
|
|
1979
|
+
}
|