Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Added config option `personalAccessToken` and environment variable `CONFLUENCE_PERSONAL_ACCESS_TOKEN` in order to support authentication using [personal access tokens](https://confluence.atlassian.com/enterprise/using-personal-access-tokens-1026032365.html). (solves [#26](https://github.com/mihaeu/cosmere/issues/26))

## [0.14.1] - 2021-08-25

### Fixed
Expand Down
58 changes: 35 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,16 @@ Sync your markdown files to confluence.

## Features

- upload new versions only when necessary
- upload/delete local images as attachments
- convert PlantUML code fence to Confluence PlantUML macro
```
- upload new versions only when necessary
- upload/delete local images as attachments
- convert PlantUML code fence to Confluence PlantUML macro
````
\```plantuml
@startuml
a --> b
@enduml
\```
```
````

## Usage

Expand All @@ -39,7 +39,7 @@ yarn add --dev cosmere

### Configuration

To get started generate configuration using
To get started generate configuration using

```bash
cosmere generate-config [--config=<path>]
Expand All @@ -49,34 +49,43 @@ which produces:

```json
{
"baseUrl": "YOUR_BASE_URL",
"user": "YOUR_USERNAME",
"pass": "YOUR_PASSWORD",
"cachePath": "build",
"prefix": "This document is automatically generated. Please don't edit it directly!",
"pages": [
{
"pageId": "1234567890",
"file": "README.md",
"title": "Optional title in the confluence page, remove to use # h1 from markdown file instead"
}
]
"baseUrl": "<your base URL>",
"user": "<your username>",
"pass": "<your password>",
"personalAccessToken": "<your personal access token (can be set instead of username/password)>",
"cachePath": "build",
"prefix": "This document is automatically generated. Please don't edit it directly!",
"pages": [
{
"pageId": "1234567890",
"file": "README.md",
"title": "Optional title in the confluence page, remove to use # h1 from markdown file instead"
}
]
}
```

### Continuous Integration

In most scenarios it is not recommended to store your credentials in the configuration file, because you will probably add it to your VCS. Instead it is recommended to provide the following environment variables in your build pipeline (GitLab CI, GitHub Actions, Jenkins, ...):
In most scenarios it is not recommended storing your credentials in the configuration file, because you will probably add it to your VCS. Instead, it is recommended to provide the following environment variables in your build pipeline (GitLab CI, GitHub Actions, Jenkins, ...):

```ini
CONFLUENCE_USERNAME=YOUR_USERNAME
CONFLUENCE_PASSWORD=YOUR_PASSWORD
```

or

```ini
CONFLUENCE_PERSONAL_ACCESS_TOKEN="<your auth token>"
```

or add it in front of the command when executing locally (add a space in front of the command when using bash in order to not write the credentials to the bash history):

```bash
CONFLUENCE_USER=YOUR_USERNAME CONFLUENCE_PASSWORD=YOUR_PASSWORD cosmere
# or
CONFLUENCE_PERSONAL_ACCESS_TOKEN="<your personal access token>" cosmere
```

### Run
Expand All @@ -99,9 +108,9 @@ or create an alias:

```json
{
"scripts": {
"pushdoc": "cosmere"
}
"scripts": {
"pushdoc": "cosmere"
}
}
```

Expand All @@ -128,11 +137,14 @@ Please, feel free to create any issues and pull request that you need.
```

## History

### md2confluence

I had various scripts that stitched markdown files together and uploaded them. I forked [`md2confluence`](https://github.com/jormar/md2confluence) by [Jormar Arellano](https://github.com/jormar) and started playing around with that, but quickly noticed that many markdown files broke due to the conversion process (wiki -> storage instead of directly to storage).

### Cosmere
The project diverged from its original intent and so I decided to rename it. [Cosmere](https://coppermind.net/wiki/Cosmere) is the wonderful universe of various books written by [Brandon Sanderson](https://www.brandonsanderson.com/). If you are into fantasy I strongly recommend checking him out.

The project diverged from its original intent and so I decided to rename it. [Cosmere](https://coppermind.net/wiki/Cosmere) is the wonderful universe of various books written by [Brandon Sanderson](https://www.brandonsanderson.com/). If you are into fantasy I strongly recommend checking him out.

## License

Expand Down
6 changes: 3 additions & 3 deletions bin/cosmere
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ Options:
`);

if (options['generate-config']) {
require('../dist/src/GenerateCommand').default(options['--config']);
require('../dist/src/cli/GenerateCommand').default(options['--config']);
} else {
require('../dist/src/MainCommand').default(options['--config'], options['--force'])
require('../dist/src/cli/MainCommand').default(options['--config'], options['--force'])
.then()
.catch(e => {
require('signale').fatal(e);
process.exit(1);
}
);
}
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"homepage": "https://mihaeu.github.io/cosmere/",
"devDependencies": {
"@types/inquirer": "^6.5.0",
"@types/jest": "^27.4.0",
"@types/marked": "^0.7.1",
"@types/node": "^12.12.7",
"@types/signale": "^1.2.1",
Expand Down
86 changes: 70 additions & 16 deletions src/ConfigLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,48 +3,82 @@ import * as fs from "fs";
import { Config } from "./types/Config";
import * as inquirer from "inquirer";
import signale from "signale";
import { FileConfig } from "./types/FileConfig";

type AuthOptions = {
user?: string;
pass?: string;
personalAccessToken?: string;
};

export class ConfigLoader {
static async load(configPath: string | null): Promise<Config> {
return await ConfigLoader.promptUserAndPassIfNotSet(
ConfigLoader.overwriteAuthFromConfigWithEnvIfPresent(ConfigLoader.readConfigFromFile(configPath)),
const fileConfig = ConfigLoader.readConfigFromFile(configPath);
const authOptions = await ConfigLoader.promptUserAndPassIfNotSet(
ConfigLoader.useAuthOptionsFromEnvIfPresent(ConfigLoader.authOptionsFromFileConfig(fileConfig)),
);
return ConfigLoader.createConfig(fileConfig, ConfigLoader.createAuthorizationToken(authOptions));
}

private static readConfigFromFile(configPath: string | null): Config {
private static readConfigFromFile(configPath: string | null, authorizationToken?: string): FileConfig {
configPath = path.resolve(configPath || path.join("cosmere.json"));
if (!fs.existsSync(configPath!)) {
signale.fatal(`File "${configPath}" not found!`);
process.exit(1);
}

let config = JSON.parse(fs.readFileSync(configPath!, "utf8")) as Config;
let config = JSON.parse(fs.readFileSync(configPath!, "utf8")) as Omit<FileConfig, "configPath">;
for (const i in config.pages) {
config.pages[i].file = path.isAbsolute(config.pages[i].file)
? config.pages[i].file
: path.resolve(path.dirname(configPath) + "/" + config.pages[i].file);
}
config.configPath = configPath;
return config;

return {
...config,
configPath,
};
}

private static createAuthorizationToken(authOptions: AuthOptions): string {
if (authOptions.personalAccessToken) {
return `Bearer ${authOptions.personalAccessToken}`;
}

if (authOptions.user && authOptions.user.length > 0 && authOptions.pass && authOptions.pass.length > 0) {
const encodedBasicToken = Buffer.from(`${authOptions.user}:${authOptions.pass}`).toString("base64");
return `Basic ${encodedBasicToken}`;
}

signale.fatal(
"Missing configuration! You must either provide a combination of your Confluence username and password or a personal access token.",
);
process.exit(2);
}

private static overwriteAuthFromConfigWithEnvIfPresent(config: Config): Config {
config.user = process.env.CONFLUENCE_USERNAME || config.user;
config.pass = process.env.CONFLUENCE_PASSWORD || config.pass;
return config;
private static useAuthOptionsFromEnvIfPresent(authOptions: AuthOptions): AuthOptions {
return {
user: process.env.CONFLUENCE_USERNAME || authOptions.user,
pass: process.env.CONFLUENCE_PASSWORD || authOptions.pass,
personalAccessToken: process.env.CONFLUENCE_PERSONAL_ACCESS_TOKEN || authOptions.personalAccessToken,
};
}

private static async promptUserAndPassIfNotSet(config: Config): Promise<Config> {
private static async promptUserAndPassIfNotSet(authOptions: AuthOptions): Promise<AuthOptions> {
if (authOptions.personalAccessToken && authOptions.personalAccessToken.length > 0) {
return authOptions;
}

const prompts = [];
if (!config.user) {
if (!authOptions.user) {
prompts.push({
type: "input",
name: "user",
message: "Your Confluence username:",
});
}

if (!config.pass) {
if (!authOptions.pass) {
prompts.push({
type: "password",
name: "pass",
Expand All @@ -53,9 +87,29 @@ export class ConfigLoader {
}

const answers = await inquirer.prompt(prompts);
config.user = config.user || (answers.user as string);
config.pass = config.pass || (answers.pass as string);
return {
user: authOptions.user || (answers.user as string),
pass: authOptions.pass || (answers.pass as string),
personalAccessToken: authOptions.personalAccessToken,
};
}

private static authOptionsFromFileConfig(fileConfig: FileConfig): AuthOptions {
return {
user: fileConfig.user,
pass: fileConfig.pass,
personalAccessToken: fileConfig.personalAccessToken,
};
}

return config;
private static createConfig(fileConfig: FileConfig, authorizationToken: string): Config {
return {
baseUrl: fileConfig.baseUrl,
cachePath: fileConfig.cachePath,
prefix: fileConfig.prefix,
pages: fileConfig.pages,
configPath: fileConfig.configPath,
authorizationToken: authorizationToken,
};
}
}
30 changes: 17 additions & 13 deletions src/ConfluenceRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,21 +37,25 @@ export default class ConfluenceRenderer extends Renderer {
}

private static renderDetailsBlock(html: string): string {
const summary = html.match(/<summary>([\s\S]*)<\/summary>/)?.[1] ?? 'Click here to expand ...'
const summary = html.match(/<summary>([\s\S]*)<\/summary>/)?.[1] ?? "Click here to expand ...";
const contentWithoutSummaryTags = html
.replace(/<summary>([\s\S]*)<\/summary>/, '')
.replace(/<\/?details>/g, '');
const content =
marked(
contentWithoutSummaryTags, {
renderer: new ConfluenceRenderer(),
xhtml: true,
});
.replace(/<summary>([\s\S]*)<\/summary>/, "")
.replace(/<\/?details>/g, "");
const content = marked(contentWithoutSummaryTags, {
renderer: new ConfluenceRenderer(),
xhtml: true,
});

return '<ac:structured-macro ac:name="expand">'
+ '<ac:parameter ac:name="title">' + summary + '</ac:parameter>'
+ '<ac:rich-text-body>' + content + '</ac:rich-text-body>'
+ '</ac:structured-macro>';
return (
'<ac:structured-macro ac:name="expand">' +
'<ac:parameter ac:name="title">' +
summary +
"</ac:parameter>" +
"<ac:rich-text-body>" +
content +
"</ac:rich-text-body>" +
"</ac:structured-macro>"
);
}

html(html: string): string {
Expand Down
30 changes: 17 additions & 13 deletions src/api/ConfluenceAPI.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,26 @@
import { AuthHeaders } from "../types/AuthHeaders";
import axios from "axios";
import * as fs from "fs";
import signale from "signale";

export class ConfluenceAPI {
private readonly authHeaders: AuthHeaders;
private readonly baseUrl: string;
private readonly authHeader: {
Authorization: string;
};

constructor(baseUrl: string, username: string, password: string) {
constructor(baseUrl: string, authorizationToken: string) {
this.baseUrl = baseUrl;
this.authHeaders = {
auth: {
username: username,
password: password,
},
this.authHeader = {
Authorization: authorizationToken,
};
}

async updateConfluencePage(pageId: string, newPage: any) {
const config = {
headers: {
...this.authHeader,
"Content-Type": "application/json",
},
...this.authHeaders,
};
try {
await axios.put(`${this.baseUrl}/content/${pageId}`, newPage, config);
Expand All @@ -33,11 +31,15 @@ export class ConfluenceAPI {
}

async deleteAttachments(pageId: string) {
const attachments = await axios.get(`${this.baseUrl}/content/${pageId}/child/attachment`, this.authHeaders);
const attachments = await axios.get(`${this.baseUrl}/content/${pageId}/child/attachment`, {
headers: this.authHeader,
});
for (const attachment of attachments.data.results) {
try {
signale.await(`Deleting attachment "${attachment.title}" ...`);
await axios.delete(`${this.baseUrl}/content/${attachment.id}`, this.authHeaders);
await axios.delete(`${this.baseUrl}/content/${attachment.id}`, {
headers: this.authHeader,
});
} catch (e) {
signale.error(`Deleting attachment "${attachment.title}" failed ...`);
}
Expand All @@ -51,18 +53,20 @@ export class ConfluenceAPI {
method: "post",
headers: {
"X-Atlassian-Token": "nocheck",
...this.authHeader,
},
data: {
file: fs.createReadStream(filename),
},
...this.authHeaders,
});
} catch (e) {
signale.error(`Uploading attachment "${filename}" failed ...`);
}
}

async currentPage(pageId: string) {
return axios.get(`${this.baseUrl}/content/${pageId}?expand=body.storage,version`, this.authHeaders);
return axios.get(`${this.baseUrl}/content/${pageId}?expand=body.storage,version`, {
headers: this.authHeader,
});
}
}
Loading