atlasctl 0.1.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/README.md +149 -0
- package/bin/atlasctl +9 -0
- package/package.json +26 -0
- package/src/cli.ts +215 -0
- package/src/config.ts +184 -0
- package/src/confluence.ts +206 -0
- package/src/llm-help.ts +42 -0
- package/src/types.ts +46 -0
package/README.md
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# atlasctl
|
|
2
|
+
|
|
3
|
+
A Bun-based CLI for Atlassian workflows.
|
|
4
|
+
|
|
5
|
+
Initial scope:
|
|
6
|
+
- Fetch a Confluence page
|
|
7
|
+
- Include all comments and nested replies
|
|
8
|
+
- Include inline comment metadata when available
|
|
9
|
+
|
|
10
|
+
## Requirements
|
|
11
|
+
|
|
12
|
+
- Bun 1.3+
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
### Local development
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
bun install
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Run directly:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
bun run src/cli.ts --help
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Global (from npm)
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm install -g atlasctl
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The CLI entrypoint uses Bun (`#!/usr/bin/env bun`), so Bun must be installed on the target machine.
|
|
35
|
+
|
|
36
|
+
## Configuration
|
|
37
|
+
|
|
38
|
+
Config file path:
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
~/.atlasctl.json
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Set required values:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
atlasctl config set site your-domain.atlassian.net
|
|
48
|
+
atlasctl config set email you@company.com
|
|
49
|
+
atlasctl config set apikey <atlassian-api-token>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Or run guided setup for all required fields:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
atlasctl config set
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Guided setup requires an interactive terminal.
|
|
59
|
+
|
|
60
|
+
Read values:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
atlasctl config get site
|
|
64
|
+
atlasctl config get email
|
|
65
|
+
atlasctl config get apikey
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Notes:
|
|
69
|
+
- `apikey` is always redacted when read (`***hidden***`).
|
|
70
|
+
- `config show` also redacts `apikey`.
|
|
71
|
+
|
|
72
|
+
## Commands
|
|
73
|
+
|
|
74
|
+
```text
|
|
75
|
+
atlasctl config set
|
|
76
|
+
atlasctl config set <site|email|apikey> <value>
|
|
77
|
+
atlasctl config get <site|email|apikey>
|
|
78
|
+
atlasctl config show
|
|
79
|
+
atlasctl confluence page fetch <id-or-url> [--output <file>] [--pretty]
|
|
80
|
+
atlasctl --help
|
|
81
|
+
atlasctl --help-llm
|
|
82
|
+
atlasctl --version
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Fetch a Confluence page
|
|
86
|
+
|
|
87
|
+
By page ID:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
atlasctl confluence page fetch 22982787097 --pretty
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
By URL:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
atlasctl confluence page fetch "https://your-domain.atlassian.net/wiki/spaces/ENG/pages/22982787097/Page+Title"
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Or write output to disk:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
atlasctl confluence page fetch 22982787097 --output page.json --pretty
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## URL and site matching
|
|
106
|
+
|
|
107
|
+
When using a URL input, the URL host must match configured `site`.
|
|
108
|
+
|
|
109
|
+
Example mismatch error:
|
|
110
|
+
- URL host: `foo.atlassian.net`
|
|
111
|
+
- Config site: `bar.atlassian.net`
|
|
112
|
+
|
|
113
|
+
The command will fail fast to avoid calling the wrong tenant.
|
|
114
|
+
|
|
115
|
+
## Output shape
|
|
116
|
+
|
|
117
|
+
`confluence page fetch` returns JSON with:
|
|
118
|
+
- `page`: core page metadata and body HTML
|
|
119
|
+
- `comments`: tree of comments and replies
|
|
120
|
+
- `meta`: fetch timestamp and total comment count
|
|
121
|
+
|
|
122
|
+
Inline comments include:
|
|
123
|
+
- `inlineContext.textSelection`
|
|
124
|
+
- `inlineContext.markerRef`
|
|
125
|
+
- `inlineContext.resolved`
|
|
126
|
+
|
|
127
|
+
## Development
|
|
128
|
+
|
|
129
|
+
Run tests:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
bun test
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Optional bundle build:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
bun run build
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Publish to npm
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
bun test
|
|
145
|
+
npm login
|
|
146
|
+
npm publish --access public
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
If `atlasctl` is already taken on npm, switch to a scoped package name (for example `@your-scope/atlasctl`) while keeping the bin name as `atlasctl`.
|
package/bin/atlasctl
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "atlasctl",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Atlassian CLI for Confluence page exports with comments",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"packageManager": "bun@1.3.8",
|
|
7
|
+
"engines": {
|
|
8
|
+
"bun": ">=1.3.0"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"src",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"bin": {
|
|
16
|
+
"atlasctl": "bin/atlasctl"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"dev": "bun run src/cli.ts",
|
|
20
|
+
"build": "rm -rf dist && bun build src/cli.ts --target bun --outfile dist/cli.js",
|
|
21
|
+
"test": "bun test"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"commander": "^14.0.3"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { writeFile } from "node:fs/promises";
|
|
4
|
+
import { Command, InvalidArgumentError } from "commander";
|
|
5
|
+
import {
|
|
6
|
+
CONFIG_KEYS,
|
|
7
|
+
type ConfigKey,
|
|
8
|
+
maskConfig,
|
|
9
|
+
normalizeConfigValue,
|
|
10
|
+
readConfig,
|
|
11
|
+
requireFetchConfig,
|
|
12
|
+
setConfigValue,
|
|
13
|
+
writeConfig,
|
|
14
|
+
} from "./config";
|
|
15
|
+
import { fetchConfluencePage } from "./confluence";
|
|
16
|
+
import { getLlmHelpText } from "./llm-help";
|
|
17
|
+
|
|
18
|
+
const VERSION = "0.1.0";
|
|
19
|
+
|
|
20
|
+
function parseConfigKey(value: string): ConfigKey {
|
|
21
|
+
if (!CONFIG_KEYS.includes(value as ConfigKey)) {
|
|
22
|
+
throw new InvalidArgumentError(
|
|
23
|
+
`Invalid config key \"${value}\". Use one of: ${CONFIG_KEYS.join(", ")}`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return value as ConfigKey;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function handleConfigSet(key: ConfigKey, value: string): Promise<void> {
|
|
31
|
+
const configPath = await setConfigValue(key, value);
|
|
32
|
+
console.log(`Saved ${key} in ${configPath}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function configPromptLabel(key: ConfigKey): string {
|
|
36
|
+
if (key === "site") return "Atlassian site (for example: your-domain.atlassian.net)";
|
|
37
|
+
if (key === "email") return "Atlassian account email";
|
|
38
|
+
return "Atlassian API key";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function displayCurrentConfigValue(key: ConfigKey, value?: string): string {
|
|
42
|
+
if (!value) {
|
|
43
|
+
return "not set";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return key === "apikey" ? "***hidden***" : value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function handleConfigSetGuided(): Promise<void> {
|
|
50
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
"Guided setup requires an interactive terminal. Use: atlasctl config set <site|email|apikey> <value>",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const config = await readConfig();
|
|
57
|
+
const updates: Partial<Record<ConfigKey, string>> = {};
|
|
58
|
+
|
|
59
|
+
for (const key of CONFIG_KEYS) {
|
|
60
|
+
while (true) {
|
|
61
|
+
const current = updates[key] ?? config[key];
|
|
62
|
+
const promptText = `${configPromptLabel(key)} [${displayCurrentConfigValue(key, current)}]: `;
|
|
63
|
+
const input = prompt(promptText)?.trim() ?? "";
|
|
64
|
+
const candidate = input || current;
|
|
65
|
+
|
|
66
|
+
if (!candidate) {
|
|
67
|
+
console.error(`${key} is required.`);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
updates[key] = normalizeConfigValue(key, candidate);
|
|
73
|
+
break;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
76
|
+
console.error(message);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const configPath = await writeConfig({
|
|
82
|
+
...config,
|
|
83
|
+
...updates,
|
|
84
|
+
});
|
|
85
|
+
console.log(`Saved site, email, apikey in ${configPath}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function handleConfigGet(key: ConfigKey): Promise<void> {
|
|
89
|
+
const config = await readConfig();
|
|
90
|
+
const value = config[key];
|
|
91
|
+
|
|
92
|
+
if (!value) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`Config key \"${key}\" is not set. Use: atlasctl config set ${key} <value>`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (key === "apikey") {
|
|
99
|
+
console.log("***hidden***");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
console.log(value);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function handleConfigShow(): Promise<void> {
|
|
107
|
+
const config = await readConfig();
|
|
108
|
+
console.log(JSON.stringify(maskConfig(config), null, 2));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function handlePageFetch(
|
|
112
|
+
idOrUrl: string,
|
|
113
|
+
options: { output?: string; pretty?: boolean },
|
|
114
|
+
): Promise<void> {
|
|
115
|
+
const config = requireFetchConfig(await readConfig());
|
|
116
|
+
const payload = await fetchConfluencePage(config, idOrUrl);
|
|
117
|
+
|
|
118
|
+
const pretty = options.pretty ?? false;
|
|
119
|
+
const json = pretty
|
|
120
|
+
? `${JSON.stringify(payload, null, 2)}\n`
|
|
121
|
+
: `${JSON.stringify(payload)}\n`;
|
|
122
|
+
|
|
123
|
+
if (options.output) {
|
|
124
|
+
await writeFile(options.output, json, "utf8");
|
|
125
|
+
console.log(`Wrote ${payload.meta.totalComments} comments to ${options.output}`);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
process.stdout.write(json);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function handleConfigSetCommand(key?: string, value?: string): Promise<void> {
|
|
133
|
+
if (!key && !value) {
|
|
134
|
+
await handleConfigSetGuided();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!key || !value) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
"Invalid config set usage. Use either: atlasctl config set <site|email|apikey> <value> or run atlasctl config set for guided setup.",
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
await handleConfigSet(parseConfigKey(key), value);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function buildProgram(): Command {
|
|
148
|
+
const program = new Command();
|
|
149
|
+
|
|
150
|
+
program
|
|
151
|
+
.name("atlasctl")
|
|
152
|
+
.description("Atlassian CLI for Confluence page exports")
|
|
153
|
+
.version(VERSION)
|
|
154
|
+
.option("--help-llm", "print concise LLM-focused usage guidance")
|
|
155
|
+
.showHelpAfterError();
|
|
156
|
+
|
|
157
|
+
const configCommand = program.command("config").description("Manage local CLI configuration");
|
|
158
|
+
|
|
159
|
+
configCommand
|
|
160
|
+
.command("set")
|
|
161
|
+
.description("Set one config value, or run guided setup with no arguments")
|
|
162
|
+
.argument("[key]", "config key: site, email, apikey")
|
|
163
|
+
.argument("[value]", "config value")
|
|
164
|
+
.action(async (key?: string, value?: string) => {
|
|
165
|
+
await handleConfigSetCommand(key, value);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
configCommand
|
|
169
|
+
.command("get")
|
|
170
|
+
.description("Get a config value")
|
|
171
|
+
.argument("<key>", "config key", parseConfigKey)
|
|
172
|
+
.action(async (key: ConfigKey) => {
|
|
173
|
+
await handleConfigGet(key);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
configCommand
|
|
177
|
+
.command("show")
|
|
178
|
+
.description("Show current config (API key is always redacted)")
|
|
179
|
+
.action(async () => {
|
|
180
|
+
await handleConfigShow();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const confluenceCommand = program.command("confluence").description("Confluence operations");
|
|
184
|
+
const pageCommand = confluenceCommand.command("page").description("Confluence page operations");
|
|
185
|
+
|
|
186
|
+
pageCommand
|
|
187
|
+
.command("fetch")
|
|
188
|
+
.description("Fetch a Confluence page and all comments")
|
|
189
|
+
.argument("<id-or-url>", "numeric page ID or full Confluence page URL")
|
|
190
|
+
.option("--output <file>", "write JSON result to file")
|
|
191
|
+
.option("--pretty", "pretty-print JSON output")
|
|
192
|
+
.action(async (idOrUrl: string, options: { output?: string; pretty?: boolean }) => {
|
|
193
|
+
await handlePageFetch(idOrUrl, options);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
return program;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function run(argv = process.argv): Promise<void> {
|
|
200
|
+
if (argv.includes("--help-llm")) {
|
|
201
|
+
process.stdout.write(`${getLlmHelpText()}\n`);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const program = buildProgram();
|
|
206
|
+
await program.parseAsync(argv);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (import.meta.main) {
|
|
210
|
+
run().catch((error) => {
|
|
211
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
212
|
+
console.error(message);
|
|
213
|
+
process.exit(1);
|
|
214
|
+
});
|
|
215
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { chmod, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { AtlasCtlConfig, RequiredConfig } from "./types";
|
|
5
|
+
|
|
6
|
+
export const CONFIG_FILENAME = ".atlasctl.json";
|
|
7
|
+
export const CONFIG_KEYS = ["site", "email", "apikey"] as const;
|
|
8
|
+
|
|
9
|
+
export type ConfigKey = (typeof CONFIG_KEYS)[number];
|
|
10
|
+
|
|
11
|
+
interface ConfigPathOptions {
|
|
12
|
+
configPath?: string;
|
|
13
|
+
homeDir?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
17
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function resolveConfigPath(options: ConfigPathOptions = {}): string {
|
|
21
|
+
if (options.configPath) {
|
|
22
|
+
return options.configPath;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const home = options.homeDir ?? homedir();
|
|
26
|
+
return path.join(home, CONFIG_FILENAME);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function normalizeSite(input: string): string {
|
|
30
|
+
const value = input.trim();
|
|
31
|
+
if (!value) {
|
|
32
|
+
throw new Error("Site cannot be empty");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const url = value.includes("://")
|
|
37
|
+
? new URL(value)
|
|
38
|
+
: new URL(`https://${value}`);
|
|
39
|
+
|
|
40
|
+
if (!url.hostname) {
|
|
41
|
+
throw new Error();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return url.host.toLowerCase();
|
|
45
|
+
} catch {
|
|
46
|
+
throw new Error("Invalid site. Use a hostname like your-domain.atlassian.net");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizeEmail(input: string): string {
|
|
51
|
+
const value = input.trim();
|
|
52
|
+
const isEmail = /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value);
|
|
53
|
+
if (!isEmail) {
|
|
54
|
+
throw new Error("Invalid email address");
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function normalizeApiKey(input: string): string {
|
|
60
|
+
const value = input.trim();
|
|
61
|
+
if (!value) {
|
|
62
|
+
throw new Error("API key cannot be empty");
|
|
63
|
+
}
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function normalizeConfigValue(key: ConfigKey, rawValue: string): string {
|
|
68
|
+
if (key === "site") {
|
|
69
|
+
return normalizeSite(rawValue);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (key === "email") {
|
|
73
|
+
return normalizeEmail(rawValue);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return normalizeApiKey(rawValue);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function normalizeConfig(raw: unknown): AtlasCtlConfig {
|
|
80
|
+
if (!isRecord(raw)) {
|
|
81
|
+
throw new Error("Config must be a JSON object");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const config: AtlasCtlConfig = {};
|
|
85
|
+
|
|
86
|
+
if (typeof raw.site === "string" && raw.site.trim()) {
|
|
87
|
+
config.site = normalizeSite(raw.site);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (typeof raw.email === "string" && raw.email.trim()) {
|
|
91
|
+
config.email = normalizeEmail(raw.email);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (typeof raw.apikey === "string" && raw.apikey.trim()) {
|
|
95
|
+
config.apikey = normalizeApiKey(raw.apikey);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return config;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function readConfig(
|
|
102
|
+
options: ConfigPathOptions = {},
|
|
103
|
+
): Promise<AtlasCtlConfig> {
|
|
104
|
+
const configPath = resolveConfigPath(options);
|
|
105
|
+
|
|
106
|
+
let raw: string;
|
|
107
|
+
try {
|
|
108
|
+
raw = await readFile(configPath, "utf8");
|
|
109
|
+
} catch (error) {
|
|
110
|
+
const fileError = error as NodeJS.ErrnoException;
|
|
111
|
+
if (fileError.code === "ENOENT") {
|
|
112
|
+
return {};
|
|
113
|
+
}
|
|
114
|
+
throw new Error(`Unable to read config at ${configPath}: ${fileError.message}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const parsed = JSON.parse(raw);
|
|
119
|
+
return normalizeConfig(parsed);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`Invalid config at ${configPath}. Ensure it is valid JSON with site/email/apikey strings.`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function writeConfig(
|
|
128
|
+
config: AtlasCtlConfig,
|
|
129
|
+
options: ConfigPathOptions = {},
|
|
130
|
+
): Promise<string> {
|
|
131
|
+
const configPath = resolveConfigPath(options);
|
|
132
|
+
const normalized = normalizeConfig(config);
|
|
133
|
+
const content = `${JSON.stringify(normalized, null, 2)}\n`;
|
|
134
|
+
|
|
135
|
+
await writeFile(configPath, content, { mode: 0o600 });
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
await chmod(configPath, 0o600);
|
|
139
|
+
} catch {
|
|
140
|
+
// Best effort permission hardening.
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return configPath;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function setConfigValue(
|
|
147
|
+
key: ConfigKey,
|
|
148
|
+
rawValue: string,
|
|
149
|
+
options: ConfigPathOptions = {},
|
|
150
|
+
): Promise<string> {
|
|
151
|
+
const config = await readConfig(options);
|
|
152
|
+
config[key] = normalizeConfigValue(key, rawValue);
|
|
153
|
+
|
|
154
|
+
return writeConfig(config, options);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function maskConfig(config: AtlasCtlConfig): AtlasCtlConfig {
|
|
158
|
+
if (!config.apikey) {
|
|
159
|
+
return { ...config };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
...config,
|
|
164
|
+
apikey: "***hidden***",
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function requireFetchConfig(config: AtlasCtlConfig): RequiredConfig {
|
|
169
|
+
const missing: ConfigKey[] = [];
|
|
170
|
+
|
|
171
|
+
for (const key of CONFIG_KEYS) {
|
|
172
|
+
if (!config[key]) {
|
|
173
|
+
missing.push(key);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (missing.length > 0) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`Missing required config values: ${missing.join(", ")}. Set each with: atlasctl config set <site|email|apikey> <value>`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return config as RequiredConfig;
|
|
184
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import type { Comment, PageExport, RequiredConfig } from "./types";
|
|
2
|
+
|
|
3
|
+
interface ApiClient {
|
|
4
|
+
baseUrl: string;
|
|
5
|
+
apiGet: (pathOrUrl: string) => Promise<any>;
|
|
6
|
+
fetchAllPages: (path: string) => Promise<any[]>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface ParsedPageInput {
|
|
10
|
+
pageId: string;
|
|
11
|
+
hostFromUrl?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function parsePageInput(idOrUrl: string): ParsedPageInput {
|
|
15
|
+
const value = idOrUrl.trim();
|
|
16
|
+
if (/^\d+$/.test(value)) {
|
|
17
|
+
return { pageId: value };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let url: URL;
|
|
21
|
+
try {
|
|
22
|
+
url = new URL(value);
|
|
23
|
+
} catch {
|
|
24
|
+
throw new Error(
|
|
25
|
+
"Invalid page identifier. Provide a numeric page ID or a full Confluence page URL.",
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const fromPath = url.pathname.match(/\/pages\/(\d+)(?:\/|$)/)?.[1];
|
|
30
|
+
const fromQuery = url.searchParams.get("pageId") ?? undefined;
|
|
31
|
+
const pageId = fromPath ?? fromQuery;
|
|
32
|
+
|
|
33
|
+
if (!pageId || !/^\d+$/.test(pageId)) {
|
|
34
|
+
throw new Error("Could not extract a numeric page ID from the provided URL.");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
pageId,
|
|
39
|
+
hostFromUrl: url.host.toLowerCase(),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function resolvePageIdForSite(
|
|
44
|
+
idOrUrl: string,
|
|
45
|
+
configuredSite: string,
|
|
46
|
+
): string {
|
|
47
|
+
const parsed = parsePageInput(idOrUrl);
|
|
48
|
+
|
|
49
|
+
if (parsed.hostFromUrl && parsed.hostFromUrl !== configuredSite.toLowerCase()) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`URL host mismatch: URL uses ${parsed.hostFromUrl} but config site is ${configuredSite}.`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return parsed.pageId;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function countComments(comments: Comment[]): number {
|
|
59
|
+
return comments.reduce((total, comment) => {
|
|
60
|
+
return total + 1 + countComments(comment.children);
|
|
61
|
+
}, 0);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseComment(raw: any): Comment {
|
|
65
|
+
const ext = raw.extensions ?? {};
|
|
66
|
+
let inlineContext: Comment["inlineContext"];
|
|
67
|
+
|
|
68
|
+
if (ext.inlineProperties) {
|
|
69
|
+
inlineContext = {
|
|
70
|
+
textSelection: ext.inlineProperties.originalSelection ?? "",
|
|
71
|
+
markerRef: ext.inlineProperties.markerRef ?? "",
|
|
72
|
+
resolved: ext.resolution?.status === "resolved",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
id: raw.id,
|
|
78
|
+
title: raw.title ?? "",
|
|
79
|
+
author:
|
|
80
|
+
raw.version?.by?.displayName ??
|
|
81
|
+
raw.history?.createdBy?.displayName ??
|
|
82
|
+
"unknown",
|
|
83
|
+
created: raw.version?.when ?? raw.history?.createdDate ?? "",
|
|
84
|
+
updated: raw.version?.when ?? "",
|
|
85
|
+
bodyHtml: raw.body?.storage?.value ?? raw.body?.view?.value ?? "",
|
|
86
|
+
inlineContext,
|
|
87
|
+
children: [],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function normalizePaginationLink(next: string): string {
|
|
92
|
+
if (next.startsWith("http://") || next.startsWith("https://")) {
|
|
93
|
+
return next;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (next.startsWith("/wiki/")) {
|
|
97
|
+
return next.slice("/wiki".length);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return next;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function createClient(config: RequiredConfig): ApiClient {
|
|
104
|
+
const baseUrl = `https://${config.site}/wiki`;
|
|
105
|
+
const auth = Buffer.from(`${config.email}:${config.apikey}`).toString("base64");
|
|
106
|
+
|
|
107
|
+
const headers = {
|
|
108
|
+
Authorization: `Basic ${auth}`,
|
|
109
|
+
Accept: "application/json",
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
async function apiGet(pathOrUrl: string): Promise<any> {
|
|
113
|
+
const url =
|
|
114
|
+
pathOrUrl.startsWith("http://") || pathOrUrl.startsWith("https://")
|
|
115
|
+
? pathOrUrl
|
|
116
|
+
: `${baseUrl}${pathOrUrl.startsWith("/") ? pathOrUrl : `/${pathOrUrl}`}`;
|
|
117
|
+
|
|
118
|
+
const response = await fetch(url, { headers });
|
|
119
|
+
|
|
120
|
+
if (!response.ok) {
|
|
121
|
+
throw new Error(`Confluence API error ${response.status} ${response.statusText}: GET ${url}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return response.json();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function fetchAllPages(path: string): Promise<any[]> {
|
|
128
|
+
const results: any[] = [];
|
|
129
|
+
let next: string | null = path;
|
|
130
|
+
|
|
131
|
+
while (next) {
|
|
132
|
+
const page = await apiGet(next);
|
|
133
|
+
if (Array.isArray(page.results)) {
|
|
134
|
+
results.push(...page.results);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
next = page._links?.next ? normalizePaginationLink(page._links.next) : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return results;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
baseUrl,
|
|
145
|
+
apiGet,
|
|
146
|
+
fetchAllPages,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function fetchReplies(client: ApiClient, commentId: string): Promise<Comment[]> {
|
|
151
|
+
const rawReplies = await client.fetchAllPages(
|
|
152
|
+
`/rest/api/content/${commentId}/child/comment?expand=body.storage,version,extensions.inlineProperties,extensions.resolution&limit=100`,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const replies: Comment[] = [];
|
|
156
|
+
for (const raw of rawReplies) {
|
|
157
|
+
const reply = parseComment(raw);
|
|
158
|
+
reply.children = await fetchReplies(client, raw.id);
|
|
159
|
+
replies.push(reply);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return replies;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function fetchConfluencePage(
|
|
166
|
+
config: RequiredConfig,
|
|
167
|
+
idOrUrl: string,
|
|
168
|
+
): Promise<PageExport> {
|
|
169
|
+
const pageId = resolvePageIdForSite(idOrUrl, config.site);
|
|
170
|
+
const client = createClient(config);
|
|
171
|
+
|
|
172
|
+
const page = await client.apiGet(
|
|
173
|
+
`/rest/api/content/${pageId}?expand=body.storage,version,history,space,metadata.labels`,
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
const rawComments = await client.fetchAllPages(
|
|
177
|
+
`/rest/api/content/${pageId}/child/comment?expand=body.storage,version,extensions.inlineProperties,extensions.resolution&limit=100`,
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const comments: Comment[] = [];
|
|
181
|
+
for (const raw of rawComments) {
|
|
182
|
+
const comment = parseComment(raw);
|
|
183
|
+
comment.children = await fetchReplies(client, raw.id);
|
|
184
|
+
comments.push(comment);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
page: {
|
|
189
|
+
id: page.id,
|
|
190
|
+
title: page.title,
|
|
191
|
+
space: page.space?.key ?? "",
|
|
192
|
+
url: `${client.baseUrl}${page._links?.webui ?? ""}`,
|
|
193
|
+
author: page.version?.by?.displayName ?? "unknown",
|
|
194
|
+
created: page.history?.createdDate ?? page.version?.when ?? "",
|
|
195
|
+
lastUpdated: page.version?.when ?? "",
|
|
196
|
+
version: page.version?.number ?? 1,
|
|
197
|
+
labels: (page.metadata?.labels?.results ?? []).map((label: any) => label.name),
|
|
198
|
+
bodyHtml: page.body?.storage?.value ?? "",
|
|
199
|
+
},
|
|
200
|
+
comments,
|
|
201
|
+
meta: {
|
|
202
|
+
fetchedAt: new Date().toISOString(),
|
|
203
|
+
totalComments: countComments(comments),
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
package/src/llm-help.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export function getLlmHelpText(): string {
|
|
2
|
+
return `atlasctl (LLM Quick Guide)
|
|
3
|
+
|
|
4
|
+
Purpose
|
|
5
|
+
- Fetch a Confluence page plus full comment tree (including inline comments) as JSON.
|
|
6
|
+
|
|
7
|
+
Required config
|
|
8
|
+
- File: ~/.atlasctl.json
|
|
9
|
+
- Keys: site, email, apikey
|
|
10
|
+
|
|
11
|
+
Fast path
|
|
12
|
+
- atlasctl config set
|
|
13
|
+
- atlasctl confluence page fetch <page-id-or-url> --pretty
|
|
14
|
+
|
|
15
|
+
Non-interactive setup
|
|
16
|
+
- atlasctl config set site <your-domain.atlassian.net>
|
|
17
|
+
- atlasctl config set email <you@example.com>
|
|
18
|
+
- atlasctl config set apikey <token>
|
|
19
|
+
|
|
20
|
+
Read config
|
|
21
|
+
- atlasctl config show
|
|
22
|
+
- atlasctl config get site
|
|
23
|
+
- atlasctl config get email
|
|
24
|
+
- atlasctl config get apikey (always prints ***hidden***)
|
|
25
|
+
|
|
26
|
+
Fetch
|
|
27
|
+
- atlasctl confluence page fetch <id-or-url> [--output <file>] [--pretty]
|
|
28
|
+
|
|
29
|
+
Input rules
|
|
30
|
+
- id-or-url can be numeric page ID, /pages/<id>/ URL, or ?pageId=<id> URL.
|
|
31
|
+
- URL host must match configured site.
|
|
32
|
+
|
|
33
|
+
Output
|
|
34
|
+
- page, comments (recursive), meta.
|
|
35
|
+
- inline comments map to comment.inlineContext: textSelection, markerRef, resolved.
|
|
36
|
+
|
|
37
|
+
Common failures
|
|
38
|
+
- Missing config keys: set site/email/apikey.
|
|
39
|
+
- Invalid URL or no page ID in URL.
|
|
40
|
+
- URL host mismatch with configured site.
|
|
41
|
+
- Guided config in non-TTY: use explicit config set key/value commands.`;
|
|
42
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export interface AtlasCtlConfig {
|
|
2
|
+
site?: string;
|
|
3
|
+
email?: string;
|
|
4
|
+
apikey?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface RequiredConfig {
|
|
8
|
+
site: string;
|
|
9
|
+
email: string;
|
|
10
|
+
apikey: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface Comment {
|
|
14
|
+
id: string;
|
|
15
|
+
title: string;
|
|
16
|
+
author: string;
|
|
17
|
+
created: string;
|
|
18
|
+
updated: string;
|
|
19
|
+
bodyHtml: string;
|
|
20
|
+
inlineContext?: {
|
|
21
|
+
textSelection: string;
|
|
22
|
+
markerRef: string;
|
|
23
|
+
resolved: boolean;
|
|
24
|
+
};
|
|
25
|
+
children: Comment[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PageExport {
|
|
29
|
+
page: {
|
|
30
|
+
id: string;
|
|
31
|
+
title: string;
|
|
32
|
+
space: string;
|
|
33
|
+
url: string;
|
|
34
|
+
author: string;
|
|
35
|
+
created: string;
|
|
36
|
+
lastUpdated: string;
|
|
37
|
+
version: number;
|
|
38
|
+
labels: string[];
|
|
39
|
+
bodyHtml: string;
|
|
40
|
+
};
|
|
41
|
+
comments: Comment[];
|
|
42
|
+
meta: {
|
|
43
|
+
fetchedAt: string;
|
|
44
|
+
totalComments: number;
|
|
45
|
+
};
|
|
46
|
+
}
|