@aaronshaf/confluence-cli 0.1.15
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/LICENSE +21 -0
- package/README.md +69 -0
- package/package.json +73 -0
- package/src/cli/commands/attachments.ts +113 -0
- package/src/cli/commands/clone.ts +188 -0
- package/src/cli/commands/comments.ts +56 -0
- package/src/cli/commands/create.ts +58 -0
- package/src/cli/commands/delete.ts +46 -0
- package/src/cli/commands/doctor.ts +161 -0
- package/src/cli/commands/duplicate-check.ts +89 -0
- package/src/cli/commands/file-rename.ts +113 -0
- package/src/cli/commands/folder-hierarchy.ts +241 -0
- package/src/cli/commands/info.ts +56 -0
- package/src/cli/commands/labels.ts +53 -0
- package/src/cli/commands/move.ts +23 -0
- package/src/cli/commands/open.ts +145 -0
- package/src/cli/commands/pull.ts +241 -0
- package/src/cli/commands/push-errors.ts +40 -0
- package/src/cli/commands/push.ts +699 -0
- package/src/cli/commands/search.ts +62 -0
- package/src/cli/commands/setup.ts +124 -0
- package/src/cli/commands/spaces.ts +42 -0
- package/src/cli/commands/status.ts +88 -0
- package/src/cli/commands/tree.ts +190 -0
- package/src/cli/help.ts +425 -0
- package/src/cli/index.ts +413 -0
- package/src/cli/utils/browser.ts +34 -0
- package/src/cli/utils/progress-reporter.ts +49 -0
- package/src/cli.ts +6 -0
- package/src/lib/config.ts +156 -0
- package/src/lib/confluence-client/attachment-operations.ts +221 -0
- package/src/lib/confluence-client/client.ts +653 -0
- package/src/lib/confluence-client/comment-operations.ts +60 -0
- package/src/lib/confluence-client/folder-operations.ts +203 -0
- package/src/lib/confluence-client/index.ts +47 -0
- package/src/lib/confluence-client/label-operations.ts +102 -0
- package/src/lib/confluence-client/page-operations.ts +270 -0
- package/src/lib/confluence-client/search-operations.ts +60 -0
- package/src/lib/confluence-client/types.ts +329 -0
- package/src/lib/confluence-client/user-operations.ts +58 -0
- package/src/lib/dependency-sorter.ts +233 -0
- package/src/lib/errors.ts +237 -0
- package/src/lib/file-scanner.ts +195 -0
- package/src/lib/formatters.ts +314 -0
- package/src/lib/health-check.ts +204 -0
- package/src/lib/markdown/converter.ts +427 -0
- package/src/lib/markdown/frontmatter.ts +116 -0
- package/src/lib/markdown/html-converter.ts +398 -0
- package/src/lib/markdown/index.ts +21 -0
- package/src/lib/markdown/link-converter.ts +189 -0
- package/src/lib/markdown/reference-updater.ts +251 -0
- package/src/lib/markdown/slugify.ts +32 -0
- package/src/lib/page-state.ts +195 -0
- package/src/lib/resolve-page-target.ts +33 -0
- package/src/lib/space-config.ts +264 -0
- package/src/lib/sync/cleanup.ts +50 -0
- package/src/lib/sync/folder-path.ts +61 -0
- package/src/lib/sync/index.ts +2 -0
- package/src/lib/sync/link-resolution-pass.ts +139 -0
- package/src/lib/sync/sync-engine.ts +681 -0
- package/src/lib/sync/sync-specific.ts +221 -0
- package/src/lib/sync/types.ts +42 -0
- package/src/test/attachments.test.ts +68 -0
- package/src/test/clone.test.ts +373 -0
- package/src/test/comments.test.ts +53 -0
- package/src/test/config.test.ts +209 -0
- package/src/test/confluence-client.test.ts +535 -0
- package/src/test/delete.test.ts +39 -0
- package/src/test/dependency-sorter.test.ts +384 -0
- package/src/test/errors.test.ts +199 -0
- package/src/test/file-rename.test.ts +305 -0
- package/src/test/file-scanner.test.ts +331 -0
- package/src/test/folder-hierarchy.test.ts +337 -0
- package/src/test/formatters.test.ts +213 -0
- package/src/test/html-converter.test.ts +399 -0
- package/src/test/info.test.ts +56 -0
- package/src/test/labels.test.ts +70 -0
- package/src/test/link-conversion-integration.test.ts +189 -0
- package/src/test/link-converter.test.ts +413 -0
- package/src/test/link-resolution-pass.test.ts +368 -0
- package/src/test/markdown.test.ts +443 -0
- package/src/test/mocks/handlers.ts +228 -0
- package/src/test/move.test.ts +53 -0
- package/src/test/msw-schema-validation.ts +151 -0
- package/src/test/page-state.test.ts +542 -0
- package/src/test/push.test.ts +551 -0
- package/src/test/reference-updater.test.ts +293 -0
- package/src/test/resolve-page-target.test.ts +55 -0
- package/src/test/search.test.ts +64 -0
- package/src/test/setup-msw.ts +75 -0
- package/src/test/space-config.test.ts +516 -0
- package/src/test/spaces.test.ts +53 -0
- package/src/test/sync-engine.test.ts +486 -0
- package/src/types/turndown-plugin-gfm.d.ts +9 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { ConfluenceClient } from '../../lib/confluence-client/index.js';
|
|
3
|
+
import { ConfigManager } from '../../lib/config.js';
|
|
4
|
+
import { EXIT_CODES } from '../../lib/errors.js';
|
|
5
|
+
import { escapeXml } from '../../lib/formatters.js';
|
|
6
|
+
|
|
7
|
+
export interface SearchCommandOptions {
|
|
8
|
+
space?: string;
|
|
9
|
+
limit?: number;
|
|
10
|
+
xml?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function searchCommand(query: string, options: SearchCommandOptions = {}): Promise<void> {
|
|
14
|
+
const configManager = new ConfigManager();
|
|
15
|
+
const config = await configManager.getConfig();
|
|
16
|
+
|
|
17
|
+
if (!config) {
|
|
18
|
+
console.error(chalk.red('Not configured. Run: cn setup'));
|
|
19
|
+
process.exit(EXIT_CODES.CONFIG_ERROR);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let cql = `type=page AND text~"${query.replace(/"/g, '\\"')}"`;
|
|
23
|
+
if (options.space) {
|
|
24
|
+
cql += ` AND space="${options.space.replace(/"/g, '\\"')}"`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const client = new ConfluenceClient(config);
|
|
28
|
+
const response = await client.search(cql, options.limit ?? 10);
|
|
29
|
+
|
|
30
|
+
if (options.xml) {
|
|
31
|
+
console.log('<results>');
|
|
32
|
+
for (const item of response.results) {
|
|
33
|
+
const content = item.content;
|
|
34
|
+
const webui = content?._links?.webui;
|
|
35
|
+
const id = content?.id ?? item.id ?? '';
|
|
36
|
+
const title = content?.title ?? item.title ?? '';
|
|
37
|
+
console.log(` <result id="${escapeXml(id)}">`);
|
|
38
|
+
console.log(` <title>${escapeXml(title)}</title>`);
|
|
39
|
+
if (item.excerpt) console.log(` <excerpt>${escapeXml(item.excerpt)}</excerpt>`);
|
|
40
|
+
if (webui) console.log(` <url>${escapeXml(`${config.confluenceUrl}/wiki${webui}`)}</url>`);
|
|
41
|
+
console.log(' </result>');
|
|
42
|
+
}
|
|
43
|
+
console.log('</results>');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (response.results.length === 0) {
|
|
48
|
+
console.log('No results found.');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const item of response.results) {
|
|
53
|
+
const content = item.content;
|
|
54
|
+
const title = content?.title ?? item.title ?? '(untitled)';
|
|
55
|
+
const id = content?.id ?? item.id ?? '';
|
|
56
|
+
const webui = content?._links?.webui;
|
|
57
|
+
const url = webui ? `${config.confluenceUrl}/wiki${webui}` : '';
|
|
58
|
+
console.log(`${chalk.bold(title)} ${chalk.gray(id)}`);
|
|
59
|
+
if (item.excerpt) console.log(` ${chalk.gray(item.excerpt)}`);
|
|
60
|
+
if (url) console.log(` ${chalk.blue(url)}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { input, password } from '@inquirer/prompts';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import ora from 'ora';
|
|
4
|
+
import { ConfluenceClient } from '../../lib/confluence-client/index.js';
|
|
5
|
+
import { ConfigManager, type Config } from '../../lib/config.js';
|
|
6
|
+
import { EXIT_CODES } from '../../lib/errors.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Interactive setup command for configuring Confluence credentials
|
|
10
|
+
*/
|
|
11
|
+
export async function setup(): Promise<void> {
|
|
12
|
+
console.log(chalk.bold('\nConfluence CLI Setup\n'));
|
|
13
|
+
console.log(chalk.gray('This wizard will help you configure your Confluence credentials.'));
|
|
14
|
+
console.log(chalk.gray('You can create an API token at: https://id.atlassian.com/manage/api-tokens\n'));
|
|
15
|
+
|
|
16
|
+
const configManager = new ConfigManager();
|
|
17
|
+
|
|
18
|
+
// Check for existing config
|
|
19
|
+
const existingConfig = await configManager.getConfig();
|
|
20
|
+
if (existingConfig) {
|
|
21
|
+
console.log(chalk.yellow('Existing configuration found:'));
|
|
22
|
+
console.log(` URL: ${existingConfig.confluenceUrl}`);
|
|
23
|
+
console.log(` Email: ${existingConfig.email}`);
|
|
24
|
+
console.log('');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Get Confluence URL
|
|
28
|
+
let confluenceUrl: string;
|
|
29
|
+
while (true) {
|
|
30
|
+
confluenceUrl = await input({
|
|
31
|
+
message: 'Confluence URL:',
|
|
32
|
+
default: existingConfig?.confluenceUrl,
|
|
33
|
+
validate: (value) => {
|
|
34
|
+
if (!value) return 'URL is required';
|
|
35
|
+
if (!ConfigManager.validateUrl(value)) {
|
|
36
|
+
return 'URL must be a Confluence Cloud URL (https://*.atlassian.net)';
|
|
37
|
+
}
|
|
38
|
+
return true;
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Normalize URL (remove trailing slashes)
|
|
43
|
+
confluenceUrl = confluenceUrl.replace(/\/+$/, '');
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Get email
|
|
48
|
+
let email: string;
|
|
49
|
+
while (true) {
|
|
50
|
+
email = await input({
|
|
51
|
+
message: 'Email:',
|
|
52
|
+
default: existingConfig?.email,
|
|
53
|
+
validate: (value) => {
|
|
54
|
+
if (!value) return 'Email is required';
|
|
55
|
+
if (!ConfigManager.validateEmail(value)) {
|
|
56
|
+
return 'Invalid email format';
|
|
57
|
+
}
|
|
58
|
+
return true;
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Get API token
|
|
65
|
+
const apiToken = await password({
|
|
66
|
+
message: 'API Token:',
|
|
67
|
+
mask: '*',
|
|
68
|
+
validate: (value) => {
|
|
69
|
+
if (!value) return 'API token is required';
|
|
70
|
+
return true;
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Create config object
|
|
75
|
+
const config: Config = {
|
|
76
|
+
confluenceUrl,
|
|
77
|
+
email,
|
|
78
|
+
apiToken,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// Verify connection
|
|
82
|
+
const spinner = ora('Verifying connection...').start();
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const client = new ConfluenceClient(config);
|
|
86
|
+
await client.verifyConnection();
|
|
87
|
+
spinner.succeed('Connected to Confluence successfully!');
|
|
88
|
+
} catch (error) {
|
|
89
|
+
spinner.fail('Connection failed');
|
|
90
|
+
|
|
91
|
+
if (error instanceof Error) {
|
|
92
|
+
if (error.message.includes('401') || error.message.includes('Invalid credentials')) {
|
|
93
|
+
console.error(chalk.red('\nAuthentication failed. Please check your email and API token.'));
|
|
94
|
+
console.log(chalk.gray('Make sure you are using an API token, not your account password.'));
|
|
95
|
+
console.log(chalk.gray('Create a token at: https://id.atlassian.com/manage/api-tokens'));
|
|
96
|
+
} else if (error.message.includes('403') || error.message.includes('Access denied')) {
|
|
97
|
+
console.error(chalk.red('\nPermission denied. Your account may not have access to Confluence.'));
|
|
98
|
+
} else if (error.message.includes('Network error') || error.message.includes('ENOTFOUND')) {
|
|
99
|
+
console.error(chalk.red('\nCould not connect to the Confluence URL.'));
|
|
100
|
+
console.log(chalk.gray('Please verify the URL is correct and accessible.'));
|
|
101
|
+
} else {
|
|
102
|
+
console.error(chalk.red(`\nError: ${error.message}`));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
process.exit(EXIT_CODES.AUTH_ERROR);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Save configuration
|
|
110
|
+
const saveSpinner = ora('Saving configuration...').start();
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
await configManager.setConfig(config);
|
|
114
|
+
saveSpinner.succeed(`Configuration saved to ${configManager.getConfigPath()}`);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
saveSpinner.fail('Failed to save configuration');
|
|
117
|
+
console.error(chalk.red(error instanceof Error ? error.message : 'Unknown error'));
|
|
118
|
+
process.exit(EXIT_CODES.CONFIG_ERROR);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
console.log('');
|
|
122
|
+
console.log(chalk.green('Setup complete!'));
|
|
123
|
+
console.log(chalk.gray('You can now use "cn sync --init <SPACE_KEY>" to sync a Confluence space.'));
|
|
124
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { ConfluenceClient } from '../../lib/confluence-client/index.js';
|
|
3
|
+
import { ConfigManager } from '../../lib/config.js';
|
|
4
|
+
import { EXIT_CODES } from '../../lib/errors.js';
|
|
5
|
+
import { escapeXml } from '../../lib/formatters.js';
|
|
6
|
+
|
|
7
|
+
export interface SpacesCommandOptions {
|
|
8
|
+
xml?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function spacesCommand(options: SpacesCommandOptions = {}): Promise<void> {
|
|
12
|
+
const configManager = new ConfigManager();
|
|
13
|
+
const config = await configManager.getConfig();
|
|
14
|
+
|
|
15
|
+
if (!config) {
|
|
16
|
+
console.error(chalk.red('Not configured. Run: cn setup'));
|
|
17
|
+
process.exit(EXIT_CODES.CONFIG_ERROR);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const client = new ConfluenceClient(config);
|
|
21
|
+
const spaces = await client.getAllSpaces();
|
|
22
|
+
|
|
23
|
+
if (options.xml) {
|
|
24
|
+
console.log('<spaces>');
|
|
25
|
+
for (const space of spaces) {
|
|
26
|
+
console.log(
|
|
27
|
+
` <space id="${escapeXml(space.id)}" key="${escapeXml(space.key)}">${escapeXml(space.name)}</space>`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
console.log('</spaces>');
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (spaces.length === 0) {
|
|
35
|
+
console.log('No spaces found.');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for (const space of spaces) {
|
|
40
|
+
console.log(`${chalk.bold(space.key)} ${space.name} ${chalk.gray(space.id)}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import { ConfluenceClient } from '../../lib/confluence-client/index.js';
|
|
4
|
+
import { ConfigManager } from '../../lib/config.js';
|
|
5
|
+
import { EXIT_CODES } from '../../lib/errors.js';
|
|
6
|
+
import { getFormatter, type StatusInfo } from '../../lib/formatters.js';
|
|
7
|
+
import { buildPageStateFromFiles } from '../../lib/page-state.js';
|
|
8
|
+
import { readSpaceConfig } from '../../lib/space-config.js';
|
|
9
|
+
import { SyncEngine } from '../../lib/sync/index.js';
|
|
10
|
+
|
|
11
|
+
export interface StatusCommandOptions {
|
|
12
|
+
xml?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Status command - shows connection and sync status
|
|
17
|
+
*/
|
|
18
|
+
export async function statusCommand(options: StatusCommandOptions = {}): Promise<void> {
|
|
19
|
+
const configManager = new ConfigManager();
|
|
20
|
+
const config = await configManager.getConfig();
|
|
21
|
+
const formatter = getFormatter(options.xml || false);
|
|
22
|
+
|
|
23
|
+
const status: StatusInfo = {
|
|
24
|
+
configured: !!config,
|
|
25
|
+
connected: false,
|
|
26
|
+
initialized: false,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// If not configured, show message and exit
|
|
30
|
+
if (!config) {
|
|
31
|
+
console.log(formatter.formatStatus(status));
|
|
32
|
+
process.exit(EXIT_CODES.CONFIG_ERROR);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
status.confluenceUrl = config.confluenceUrl;
|
|
36
|
+
status.email = config.email;
|
|
37
|
+
|
|
38
|
+
// Check connection
|
|
39
|
+
const spinner = options.xml ? null : ora('Checking connection...').start();
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const client = new ConfluenceClient(config);
|
|
43
|
+
await client.verifyConnection();
|
|
44
|
+
status.connected = true;
|
|
45
|
+
spinner?.succeed('Connected to Confluence');
|
|
46
|
+
} catch (_error) {
|
|
47
|
+
status.connected = false;
|
|
48
|
+
spinner?.fail('Not connected');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Check space configuration
|
|
52
|
+
const directory = process.cwd();
|
|
53
|
+
const spaceConfig = readSpaceConfig(directory);
|
|
54
|
+
|
|
55
|
+
if (spaceConfig) {
|
|
56
|
+
status.initialized = true;
|
|
57
|
+
status.spaceKey = spaceConfig.spaceKey;
|
|
58
|
+
status.spaceName = spaceConfig.spaceName;
|
|
59
|
+
status.lastSync = spaceConfig.lastSync;
|
|
60
|
+
status.pageCount = Object.keys(spaceConfig.pages).length;
|
|
61
|
+
|
|
62
|
+
// Check for pending changes if connected
|
|
63
|
+
if (status.connected) {
|
|
64
|
+
try {
|
|
65
|
+
const syncEngine = new SyncEngine(config);
|
|
66
|
+
const remotePages = await syncEngine.fetchPageTree(spaceConfig.spaceId);
|
|
67
|
+
// Per ADR-0024: Build PageStateCache for version comparison from frontmatter
|
|
68
|
+
const pageState = buildPageStateFromFiles(directory, spaceConfig.pages);
|
|
69
|
+
const diff = syncEngine.computeDiff(remotePages, spaceConfig, pageState);
|
|
70
|
+
|
|
71
|
+
status.pendingChanges = {
|
|
72
|
+
added: diff.added.length,
|
|
73
|
+
modified: diff.modified.length,
|
|
74
|
+
deleted: diff.deleted.length,
|
|
75
|
+
};
|
|
76
|
+
} catch (_error) {
|
|
77
|
+
// Ignore errors when checking pending changes
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Clear spinner if it's still running
|
|
83
|
+
spinner?.stop();
|
|
84
|
+
|
|
85
|
+
// Output status
|
|
86
|
+
console.log('');
|
|
87
|
+
console.log(formatter.formatStatus(status));
|
|
88
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import { ConfluenceClient, type Page, type PageTreeNode } from '../../lib/confluence-client/index.js';
|
|
4
|
+
import { ConfigManager } from '../../lib/config.js';
|
|
5
|
+
import { EXIT_CODES } from '../../lib/errors.js';
|
|
6
|
+
import { getFormatter, type TreeNode } from '../../lib/formatters.js';
|
|
7
|
+
import { readSpaceConfig } from '../../lib/space-config.js';
|
|
8
|
+
import { SyncEngine } from '../../lib/sync/index.js';
|
|
9
|
+
|
|
10
|
+
export interface TreeCommandOptions {
|
|
11
|
+
spaceKey?: string;
|
|
12
|
+
remote?: boolean;
|
|
13
|
+
depth?: number;
|
|
14
|
+
xml?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Convert PageTreeNode to TreeNode format for formatter
|
|
19
|
+
*/
|
|
20
|
+
function convertToTreeNode(node: PageTreeNode, maxDepth?: number, currentDepth = 0): TreeNode {
|
|
21
|
+
const shouldIncludeChildren = maxDepth === undefined || currentDepth < maxDepth;
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
id: node.page.id,
|
|
25
|
+
title: node.page.title,
|
|
26
|
+
children: shouldIncludeChildren
|
|
27
|
+
? node.children.map((child) => convertToTreeNode(child, maxDepth, currentDepth + 1))
|
|
28
|
+
: [],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build tree from flat page list
|
|
34
|
+
*/
|
|
35
|
+
function buildTree(pages: Page[]): PageTreeNode[] {
|
|
36
|
+
const pageMap = new Map<string, PageTreeNode>();
|
|
37
|
+
const roots: PageTreeNode[] = [];
|
|
38
|
+
|
|
39
|
+
// Create nodes for all pages
|
|
40
|
+
for (const page of pages) {
|
|
41
|
+
pageMap.set(page.id, { page, children: [] });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Build tree structure
|
|
45
|
+
for (const page of pages) {
|
|
46
|
+
const node = pageMap.get(page.id);
|
|
47
|
+
if (!node) continue;
|
|
48
|
+
if (page.parentId && pageMap.has(page.parentId)) {
|
|
49
|
+
pageMap.get(page.parentId)?.children.push(node);
|
|
50
|
+
} else {
|
|
51
|
+
roots.push(node);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Sort children alphabetically
|
|
56
|
+
const sortChildren = (nodes: PageTreeNode[]): void => {
|
|
57
|
+
nodes.sort((a, b) => a.page.title.localeCompare(b.page.title));
|
|
58
|
+
for (const node of nodes) {
|
|
59
|
+
sortChildren(node.children);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
sortChildren(roots);
|
|
63
|
+
|
|
64
|
+
return roots;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Tree command - displays page hierarchy
|
|
69
|
+
*/
|
|
70
|
+
export async function treeCommand(options: TreeCommandOptions = {}): Promise<void> {
|
|
71
|
+
const configManager = new ConfigManager();
|
|
72
|
+
const config = await configManager.getConfig();
|
|
73
|
+
const formatter = getFormatter(options.xml || false);
|
|
74
|
+
|
|
75
|
+
if (!config) {
|
|
76
|
+
console.error(chalk.red('Not configured. Please run "cn setup" first.'));
|
|
77
|
+
process.exit(EXIT_CODES.CONFIG_ERROR);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const directory = process.cwd();
|
|
81
|
+
let spaceId: string | undefined;
|
|
82
|
+
let spaceKey: string | undefined;
|
|
83
|
+
|
|
84
|
+
// Determine space to use
|
|
85
|
+
if (options.spaceKey) {
|
|
86
|
+
// Get space by key
|
|
87
|
+
const spinner = options.xml ? null : ora(`Fetching space ${options.spaceKey}...`).start();
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const client = new ConfluenceClient(config);
|
|
91
|
+
const space = await client.getSpaceByKey(options.spaceKey);
|
|
92
|
+
spaceId = space.id;
|
|
93
|
+
spaceKey = space.key;
|
|
94
|
+
spinner?.succeed(`Found space: ${space.name}`);
|
|
95
|
+
} catch (_error) {
|
|
96
|
+
spinner?.fail(`Space "${options.spaceKey}" not found`);
|
|
97
|
+
process.exit(EXIT_CODES.SPACE_NOT_FOUND);
|
|
98
|
+
}
|
|
99
|
+
} else {
|
|
100
|
+
// Use space from current directory
|
|
101
|
+
const spaceConfig = readSpaceConfig(directory);
|
|
102
|
+
if (!spaceConfig) {
|
|
103
|
+
console.error(chalk.red('No space configured in this directory.'));
|
|
104
|
+
console.log(chalk.gray('Specify a space key or run "cn sync --init <SPACE_KEY>" first.'));
|
|
105
|
+
process.exit(EXIT_CODES.CONFIG_ERROR);
|
|
106
|
+
}
|
|
107
|
+
spaceId = spaceConfig.spaceId;
|
|
108
|
+
spaceKey = spaceConfig.spaceKey;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Fetch pages
|
|
112
|
+
const spinner = options.xml ? null : ora('Fetching page tree...').start();
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
let pages: Page[];
|
|
116
|
+
|
|
117
|
+
if (options.remote !== false) {
|
|
118
|
+
// Fetch from API
|
|
119
|
+
if (!spaceId) {
|
|
120
|
+
spinner?.fail('Space ID not available');
|
|
121
|
+
process.exit(EXIT_CODES.CONFIG_ERROR);
|
|
122
|
+
}
|
|
123
|
+
const syncEngine = new SyncEngine(config);
|
|
124
|
+
pages = await syncEngine.fetchPageTree(spaceId);
|
|
125
|
+
} else {
|
|
126
|
+
// Use local cache
|
|
127
|
+
const spaceConfig = readSpaceConfig(directory);
|
|
128
|
+
if (!spaceConfig) {
|
|
129
|
+
spinner?.fail('No local sync state found. Use --remote to fetch from API.');
|
|
130
|
+
process.exit(EXIT_CODES.CONFIG_ERROR);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Build pages from sync state, inferring hierarchy from file paths
|
|
134
|
+
// Per ADR-0024: pages is now Record<string, string> (pageId -> localPath)
|
|
135
|
+
// Create a map of directory paths to page IDs for parent lookup
|
|
136
|
+
// Note: sync-engine uses README.md for directory index files (per ADR-0005)
|
|
137
|
+
const pathToPageId = new Map<string, string>();
|
|
138
|
+
for (const [pageId, localPath] of Object.entries(spaceConfig.pages)) {
|
|
139
|
+
// For README.md files (directory index), map the parent directory
|
|
140
|
+
if (localPath.endsWith('/README.md') || localPath === 'README.md') {
|
|
141
|
+
const dir = localPath.replace('/README.md', '').replace('README.md', '');
|
|
142
|
+
pathToPageId.set(dir, pageId);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
pages = Object.entries(spaceConfig.pages).map(([pageId, localPath]) => {
|
|
147
|
+
// Extract title from filename
|
|
148
|
+
const filename = localPath.split('/').pop() || '';
|
|
149
|
+
const title = filename.replace('.md', '').replace(/^README$/i, '') || pageId;
|
|
150
|
+
|
|
151
|
+
// Infer parent from path structure
|
|
152
|
+
const pathParts = localPath.split('/');
|
|
153
|
+
let parentId: string | null = null;
|
|
154
|
+
|
|
155
|
+
if (pathParts.length > 1) {
|
|
156
|
+
// For regular files, parent is the directory's README.md
|
|
157
|
+
// For README.md, parent is the grandparent directory's README.md
|
|
158
|
+
const isReadme = localPath.endsWith('/README.md');
|
|
159
|
+
const parentDir = isReadme ? pathParts.slice(0, -2).join('/') : pathParts.slice(0, -1).join('/');
|
|
160
|
+
|
|
161
|
+
if (parentDir) {
|
|
162
|
+
parentId = pathToPageId.get(parentDir) || null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
id: pageId,
|
|
168
|
+
title: title || localPath,
|
|
169
|
+
spaceId: spaceConfig.spaceId,
|
|
170
|
+
parentId,
|
|
171
|
+
};
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
spinner?.succeed(`Found ${pages.length} pages`);
|
|
176
|
+
|
|
177
|
+
// Build tree
|
|
178
|
+
const tree = buildTree(pages);
|
|
179
|
+
const treeNodes = tree.map((node) => convertToTreeNode(node, options.depth));
|
|
180
|
+
|
|
181
|
+
// Output tree
|
|
182
|
+
console.log('');
|
|
183
|
+
console.log(options.xml ? '' : chalk.bold(`${spaceKey}:`));
|
|
184
|
+
console.log(formatter.formatTree(treeNodes));
|
|
185
|
+
} catch (error) {
|
|
186
|
+
spinner?.fail('Failed to fetch page tree');
|
|
187
|
+
console.error(chalk.red(error instanceof Error ? error.message : 'Unknown error'));
|
|
188
|
+
process.exit(EXIT_CODES.GENERAL_ERROR);
|
|
189
|
+
}
|
|
190
|
+
}
|