@stravica-ai/digital-operator-pa 0.1.40 → 0.1.41
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 +5 -0
- package/bin/confluence +9 -0
- package/bin/confluence-fetch +9 -0
- package/bin/jira +9 -0
- package/dist/engineering-pa-core/knowledge/platform-tooling/workspace-extensions.md +62 -0
- package/dist/engineering-pa-core-extensions-atlassian/README.md +81 -16
- package/dist/engineering-pa-core-extensions-atlassian/bin/confluence +2 -0
- package/dist/engineering-pa-core-extensions-atlassian/bin/confluence-fetch +2 -0
- package/dist/engineering-pa-core-extensions-atlassian/bin/jira +2 -0
- package/dist/engineering-pa-core-extensions-atlassian/config/jira-workflow-chains.default.json +4 -0
- package/dist/engineering-pa-core-extensions-atlassian/index.mjs +17 -6
- package/dist/engineering-pa-core-extensions-atlassian/package.json +11 -4
- package/dist/engineering-pa-core-extensions-atlassian/src/common/invalidate-client.mjs +137 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/common/pa-name.mjs +58 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/common/secrets.mjs +158 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/cli.mjs +165 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/client.mjs +294 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/auth.mjs +59 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/comment.mjs +174 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/create.mjs +136 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/label.mjs +98 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mine.mjs +105 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mirror-folder.mjs +487 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mirror-page.mjs +351 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/page.mjs +127 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/pages.mjs +107 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/recent.mjs +107 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/search.mjs +123 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/spaces.mjs +82 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/test.mjs +155 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/tree.mjs +103 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/update.mjs +134 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/format.mjs +260 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/mirror.mjs +353 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence/storage.mjs +880 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence-fetch/cli.mjs +313 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/confluence-fetch/fetch.mjs +327 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/adf.mjs +388 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/bug-fields.mjs +294 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/cli.mjs +197 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/client.mjs +350 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/advance.mjs +486 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/attach.mjs +129 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/auth.mjs +53 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/boards.mjs +88 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/bulk-transition.mjs +223 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/cancel.mjs +135 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/comment.mjs +169 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/create.mjs +376 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/issue.mjs +105 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/link.mjs +204 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/mine.mjs +97 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/projects.mjs +91 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/search.mjs +80 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/sprint.mjs +111 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/sync-statuses.mjs +313 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/test.mjs +142 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/transition.mjs +197 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/update.mjs +284 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/worklog.mjs +380 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/format.mjs +267 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/invalidate.mjs +72 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/metadata.mjs +225 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/required-fields.mjs +334 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/workflow-chains.mjs +405 -0
- package/dist/engineering-pa-core-extensions-atlassian/src/jira/workflow-map.mjs +107 -0
- package/dist/engineering-pa-scaffold/README.md +32 -1
- package/dist/engineering-pa-scaffold/src/cli.mjs +83 -6
- package/dist/engineering-pa-scaffold/src/commands/init-workspace-extension.mjs +234 -0
- package/dist/engineering-pa-scaffold/src/commands/init.mjs +6 -5
- package/dist/engineering-pa-scaffold/src/generator.mjs +104 -2
- package/dist/engineering-pa-scaffold/templates/CLAUDE.md.template +5 -5
- package/dist/engineering-pa-types-engineering/index.mjs +14 -6
- package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/create-a-small-app.md +81 -0
- package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/for-agents.md +25 -0
- package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/index.md +34 -0
- package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/reference.md +16 -0
- package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/wire-your-agent.md +17 -0
- package/dist/engineering-pa-types-engineering/package.json +2 -2
- package/dist/engineering-pa-types-engineering/roles/rcf-lite-engineer/definition.md +183 -0
- package/docs/onboarding-solo.md +55 -4
- package/docs/what-it-can-do.md +85 -0
- package/package.json +5 -2
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// cli.mjs - Confluence CLI command router.
|
|
2
|
+
//
|
|
3
|
+
// Routes subcommands to handler modules. Handles help text, unknown commands,
|
|
4
|
+
// and maps ConfluenceError to process exit codes.
|
|
5
|
+
//
|
|
6
|
+
// Exit codes:
|
|
7
|
+
// 0 - success
|
|
8
|
+
// 1 - usage error (bad args, unknown command)
|
|
9
|
+
// 2 - API/logical error (page not found, auth failed)
|
|
10
|
+
// 3 - transport/IO failure (network error, vault unavailable)
|
|
11
|
+
|
|
12
|
+
import { ConfluenceError } from './client.mjs';
|
|
13
|
+
|
|
14
|
+
// ── Help text ────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
const HELP = `confluence - Confluence CLI for your Atlassian site
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
confluence <command> [options]
|
|
20
|
+
|
|
21
|
+
Commands:
|
|
22
|
+
auth Verify credentials and show current user
|
|
23
|
+
spaces [--filter Q] [--status S] List spaces (default: current)
|
|
24
|
+
pages <SPACE_KEY> [--limit N] List pages in a space
|
|
25
|
+
page <ID> [--format md|storage|text] [--save FILE]
|
|
26
|
+
Get page content
|
|
27
|
+
search <CQL> [--max N] [--expand] CQL search
|
|
28
|
+
create -s SPACE -t TITLE [--from-file FILE.md] [-b BODY] [--parent ID]
|
|
29
|
+
Create a page
|
|
30
|
+
update <ID> [--from-file FILE.md] [-t TITLE] [-b BODY] [-m MSG]
|
|
31
|
+
Update a page
|
|
32
|
+
comment <ID> [TEXT] [--list] Add or list comments
|
|
33
|
+
label <ID> <LABEL...> [--remove] Add or remove labels
|
|
34
|
+
tree <ID> Show page tree (ancestors + children)
|
|
35
|
+
recent [--space KEY] [--days N] Recently modified pages
|
|
36
|
+
mine [--days N] Current user's pages
|
|
37
|
+
mirror-folder <FOLDER_ID> [--target DIR] [--dry-run] [--no-reindex]
|
|
38
|
+
Mirror a folder subtree to local markdown
|
|
39
|
+
mirror-page <PAGE_ID> [--target DIR] [--dry-run] [--no-reindex]
|
|
40
|
+
Mirror a page subtree (descendants) to local markdown
|
|
41
|
+
test Live API smoke test
|
|
42
|
+
|
|
43
|
+
Run 'confluence <command> --help' for command-specific help.
|
|
44
|
+
`;
|
|
45
|
+
|
|
46
|
+
// ── Arg parser ───────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parse argv into flags and positional args.
|
|
50
|
+
* Handles --flag value, -f value, and boolean flags.
|
|
51
|
+
*
|
|
52
|
+
* @param {string[]} argv Arguments after the command name
|
|
53
|
+
* @returns {{ _: string[], [key: string]: string|boolean }}
|
|
54
|
+
*/
|
|
55
|
+
export function parseArgs(argv) {
|
|
56
|
+
const args = { _: [] };
|
|
57
|
+
for (let i = 0; i < argv.length; i++) {
|
|
58
|
+
if (argv[i] === '--') {
|
|
59
|
+
// Everything after -- is positional
|
|
60
|
+
args._.push(...argv.slice(i + 1));
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
if (argv[i].startsWith('--')) {
|
|
64
|
+
const key = argv[i].slice(2);
|
|
65
|
+
// Boolean flag if next arg is also a flag or end of args
|
|
66
|
+
if (i + 1 >= argv.length || argv[i + 1].startsWith('-')) {
|
|
67
|
+
args[key] = true;
|
|
68
|
+
} else {
|
|
69
|
+
args[key] = argv[++i];
|
|
70
|
+
}
|
|
71
|
+
} else if (argv[i].startsWith('-') && argv[i].length === 2) {
|
|
72
|
+
const key = argv[i].slice(1);
|
|
73
|
+
if (i + 1 >= argv.length || argv[i + 1].startsWith('-')) {
|
|
74
|
+
args[key] = true;
|
|
75
|
+
} else {
|
|
76
|
+
args[key] = argv[++i];
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
args._.push(argv[i]);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return args;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Command loading ──────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
const COMMANDS = {
|
|
88
|
+
auth: () => import('./commands/auth.mjs'),
|
|
89
|
+
spaces: () => import('./commands/spaces.mjs'),
|
|
90
|
+
pages: () => import('./commands/pages.mjs'),
|
|
91
|
+
page: () => import('./commands/page.mjs'),
|
|
92
|
+
search: () => import('./commands/search.mjs'),
|
|
93
|
+
create: () => import('./commands/create.mjs'),
|
|
94
|
+
update: () => import('./commands/update.mjs'),
|
|
95
|
+
comment: () => import('./commands/comment.mjs'),
|
|
96
|
+
label: () => import('./commands/label.mjs'),
|
|
97
|
+
tree: () => import('./commands/tree.mjs'),
|
|
98
|
+
recent: () => import('./commands/recent.mjs'),
|
|
99
|
+
mine: () => import('./commands/mine.mjs'),
|
|
100
|
+
'mirror-folder': () => import('./commands/mirror-folder.mjs'),
|
|
101
|
+
'mirror-page': () => import('./commands/mirror-page.mjs'),
|
|
102
|
+
test: () => import('./commands/test.mjs'),
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// ── Router ───────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Run the CLI router.
|
|
109
|
+
*
|
|
110
|
+
* @param {string[]} argv process.argv.slice(2)
|
|
111
|
+
* @param {object} [deps] Dependency overrides for testing
|
|
112
|
+
* @param {object} [deps.client] Confluence client instance
|
|
113
|
+
* @param {Function} [deps.log] Output function (default: console.log)
|
|
114
|
+
* @param {Function} [deps.logError] Error output (default: console.error)
|
|
115
|
+
* @returns {Promise<number>} Exit code
|
|
116
|
+
*/
|
|
117
|
+
export async function run(argv, deps = {}) {
|
|
118
|
+
const log = deps.log || console.log;
|
|
119
|
+
const logError = deps.logError || console.error;
|
|
120
|
+
|
|
121
|
+
const cmd = argv[0];
|
|
122
|
+
|
|
123
|
+
// No command or help
|
|
124
|
+
if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
125
|
+
log(HELP);
|
|
126
|
+
return 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Unknown command
|
|
130
|
+
const loader = COMMANDS[cmd];
|
|
131
|
+
if (!loader) {
|
|
132
|
+
logError(`Unknown command: ${cmd}\n`);
|
|
133
|
+
log(HELP);
|
|
134
|
+
return 1;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Load and run command
|
|
138
|
+
const mod = await loader();
|
|
139
|
+
const rest = argv.slice(1);
|
|
140
|
+
return mod.run(rest, deps);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* CLI entry point. Uses `process.exitCode` (not `process.exit`) so pending
|
|
145
|
+
* piped-stdout writes drain before Node exits - exit-before-drain
|
|
146
|
+
* truncates at the first pipe-buffer boundary (~8KB on macOS). Same class
|
|
147
|
+
* as w-2026-08-31-dave-039.
|
|
148
|
+
*
|
|
149
|
+
* @param {string[]} argv process.argv.slice(2)
|
|
150
|
+
*/
|
|
151
|
+
export async function main(argv) {
|
|
152
|
+
try {
|
|
153
|
+
const code = await run(argv);
|
|
154
|
+
process.exitCode = code ?? 0;
|
|
155
|
+
} catch (err) {
|
|
156
|
+
if (err instanceof ConfluenceError) {
|
|
157
|
+
console.error(`confluence: ${err.message}`);
|
|
158
|
+
process.exitCode = err.exitCode;
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
// Unexpected error
|
|
162
|
+
console.error(`confluence: ${err.message || err}`);
|
|
163
|
+
process.exitCode = 3;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// client.mjs - Confluence Cloud REST API client.
|
|
2
|
+
//
|
|
3
|
+
// Handles credential loading (vault), auth, request building, error handling,
|
|
4
|
+
// rate-limit retry, and two pagination models:
|
|
5
|
+
// - Offset-based (v1 endpoints: CQL search, labels, comments, page tree)
|
|
6
|
+
// - Cursor-based (v2 endpoints: page CRUD, reading labels/comments/attachments)
|
|
7
|
+
//
|
|
8
|
+
// Reuses JIRA_* vault secrets - same Atlassian site.
|
|
9
|
+
// Zero external dependencies - uses Node.js built-in fetch() and child_process.
|
|
10
|
+
|
|
11
|
+
import { loadAtlassianCredentials } from '../common/secrets.mjs';
|
|
12
|
+
|
|
13
|
+
// ---- Exit-code-aware error ----------------------------------------------
|
|
14
|
+
|
|
15
|
+
export class ConfluenceError extends Error {
|
|
16
|
+
constructor(message, exitCode = 2) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = 'ConfluenceError';
|
|
19
|
+
this.exitCode = exitCode;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ---- Credential loading -------------------------------------------------
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Load the three Atlassian credentials from env vars first, else the
|
|
27
|
+
* platform `secrets` bin. Reuses JIRA_* keys for both Jira and Confluence
|
|
28
|
+
* because they target the same Atlassian site.
|
|
29
|
+
* @param {object} opts { secretsGet } override for testing
|
|
30
|
+
* @returns {{ domain: string, email: string, apiToken: string }}
|
|
31
|
+
*/
|
|
32
|
+
export function getCredentials(opts = {}) {
|
|
33
|
+
try {
|
|
34
|
+
return loadAtlassianCredentials(opts);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
throw new ConfluenceError(err.message, 3);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── Client factory ───────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Create a Confluence API client.
|
|
44
|
+
*
|
|
45
|
+
* @param {object} [options]
|
|
46
|
+
* @param {string} [options.domain] Atlassian subdomain (skips vault read)
|
|
47
|
+
* @param {string} [options.email] Auth email (skips vault read)
|
|
48
|
+
* @param {string} [options.apiToken] API token (skips vault read)
|
|
49
|
+
* @param {string} [options.secretsBin] Path to secrets CLI (testing)
|
|
50
|
+
* @param {Function} [options.fetchFn] fetch() override (testing)
|
|
51
|
+
* @param {number} [options.maxRetries] Max retries on 429/5xx (default: 1)
|
|
52
|
+
* @returns {object} Client with request, get, post, put, del, paginateOffset, paginateCursor, getCredentials
|
|
53
|
+
*/
|
|
54
|
+
export function createClient(options = {}) {
|
|
55
|
+
const fetchFn = options.fetchFn ?? globalThis.fetch;
|
|
56
|
+
const maxRetries = options.maxRetries ?? 1;
|
|
57
|
+
|
|
58
|
+
// Credentials are loaded lazily on first use, unless explicitly provided
|
|
59
|
+
let _creds = null;
|
|
60
|
+
|
|
61
|
+
function creds() {
|
|
62
|
+
if (_creds) return _creds;
|
|
63
|
+
if (options.domain && options.email && options.apiToken) {
|
|
64
|
+
_creds = {
|
|
65
|
+
domain: options.domain,
|
|
66
|
+
email: options.email,
|
|
67
|
+
apiToken: options.apiToken,
|
|
68
|
+
};
|
|
69
|
+
} else {
|
|
70
|
+
_creds = getCredentials({ secretsBin: options.secretsBin });
|
|
71
|
+
}
|
|
72
|
+
return _creds;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function baseUrl() {
|
|
76
|
+
return `https://${creds().domain}.atlassian.net`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function authHeader() {
|
|
80
|
+
const { email, apiToken } = creds();
|
|
81
|
+
return `Basic ${Buffer.from(`${email}:${apiToken}`).toString('base64')}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Core request ─────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Make a Confluence API request.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} method HTTP method
|
|
90
|
+
* @param {string} path URL path (e.g. /wiki/api/v2/pages/123)
|
|
91
|
+
* @param {object} [body] Request body (will be JSON-stringified)
|
|
92
|
+
* @param {object} [headers] Extra headers to merge
|
|
93
|
+
* @returns {Promise<{ status: number, data: any, headers: Headers }>}
|
|
94
|
+
*/
|
|
95
|
+
async function request(method, path, body, headers = {}) {
|
|
96
|
+
const url = `${baseUrl()}${path}`;
|
|
97
|
+
const reqHeaders = {
|
|
98
|
+
'Authorization': authHeader(),
|
|
99
|
+
'Accept': 'application/json',
|
|
100
|
+
...headers,
|
|
101
|
+
};
|
|
102
|
+
if (body !== undefined && body !== null) {
|
|
103
|
+
reqHeaders['Content-Type'] = 'application/json';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const fetchOpts = {
|
|
107
|
+
method,
|
|
108
|
+
headers: reqHeaders,
|
|
109
|
+
};
|
|
110
|
+
if (body !== undefined && body !== null) {
|
|
111
|
+
fetchOpts.body = JSON.stringify(body);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let lastError = null;
|
|
115
|
+
|
|
116
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
117
|
+
let res;
|
|
118
|
+
try {
|
|
119
|
+
res = await fetchFn(url, fetchOpts);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
lastError = new ConfluenceError(`Network error: ${err.message}`, 3);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Rate limited - wait and retry
|
|
126
|
+
if (res.status === 429) {
|
|
127
|
+
const retryAfter = parseInt(res.headers.get('Retry-After') || '5', 10);
|
|
128
|
+
if (attempt < maxRetries) {
|
|
129
|
+
await sleep(retryAfter * 1000);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
throw new ConfluenceError(`Rate limited (429). Retry-After: ${retryAfter}s`, 2);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 5xx - retry if attempts remain, otherwise throw immediately
|
|
136
|
+
if (res.status >= 500) {
|
|
137
|
+
if (attempt < maxRetries) {
|
|
138
|
+
lastError = new ConfluenceError(`Server error: ${res.status}`, 2);
|
|
139
|
+
await sleep(1000);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
throw new ConfluenceError(`Server error: ${res.status}`, 2);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Parse response
|
|
146
|
+
let data = null;
|
|
147
|
+
if (res.status !== 204) {
|
|
148
|
+
const contentType = res.headers.get('Content-Type') || '';
|
|
149
|
+
if (contentType.includes('application/json')) {
|
|
150
|
+
data = await res.json();
|
|
151
|
+
} else {
|
|
152
|
+
const text = await res.text();
|
|
153
|
+
if (text) data = text;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 4xx errors
|
|
158
|
+
if (res.status >= 400) {
|
|
159
|
+
const msgs = [];
|
|
160
|
+
if (data && data.errorMessages) msgs.push(...data.errorMessages);
|
|
161
|
+
if (data && data.errors) {
|
|
162
|
+
if (Array.isArray(data.errors)) {
|
|
163
|
+
for (const e of data.errors) {
|
|
164
|
+
msgs.push(e.title || e.message || JSON.stringify(e));
|
|
165
|
+
}
|
|
166
|
+
} else {
|
|
167
|
+
for (const [field, msg] of Object.entries(data.errors)) {
|
|
168
|
+
msgs.push(`${field}: ${msg}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (data && data.message) msgs.push(data.message);
|
|
173
|
+
const detail = msgs.length > 0 ? msgs.join('; ') : `HTTP ${res.status}`;
|
|
174
|
+
throw new ConfluenceError(`Confluence API error (${res.status}): ${detail}`, 2);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return { status: res.status, data, headers: res.headers };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// All retries exhausted
|
|
181
|
+
throw lastError || new ConfluenceError('Request failed after retries', 3);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── Convenience methods ──────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
async function get(path) {
|
|
187
|
+
return request('GET', path);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function post(path, body) {
|
|
191
|
+
return request('POST', path, body);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function put(path, body) {
|
|
195
|
+
return request('PUT', path, body);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function del(path) {
|
|
199
|
+
return request('DELETE', path);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── Offset-based pagination (v1 endpoints) ────────────────────────
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Async generator for offset-based paginated endpoints (v1).
|
|
206
|
+
* Used for CQL search, labels, comments, page tree.
|
|
207
|
+
*
|
|
208
|
+
* @param {string} path Base path (e.g. /wiki/rest/api/content/search)
|
|
209
|
+
* @param {object} [params] Extra query params
|
|
210
|
+
* @param {number} [pageSize] Results per page (default: 25)
|
|
211
|
+
* @yields {object} Each item from the paginated results
|
|
212
|
+
*/
|
|
213
|
+
async function* paginateOffset(path, params = {}, pageSize = 25) {
|
|
214
|
+
let start = 0;
|
|
215
|
+
let hasMore = true;
|
|
216
|
+
|
|
217
|
+
while (hasMore) {
|
|
218
|
+
const query = new URLSearchParams({
|
|
219
|
+
...params,
|
|
220
|
+
start: String(start),
|
|
221
|
+
limit: String(pageSize),
|
|
222
|
+
});
|
|
223
|
+
const fullPath = `${path}?${query.toString()}`;
|
|
224
|
+
const { data } = await get(fullPath);
|
|
225
|
+
|
|
226
|
+
const items = data.results || [];
|
|
227
|
+
for (const item of items) {
|
|
228
|
+
yield item;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// v1 uses _links.next or size < limit to signal end
|
|
232
|
+
hasMore = data._links?.next != null && items.length >= pageSize;
|
|
233
|
+
start += items.length;
|
|
234
|
+
|
|
235
|
+
if (items.length === 0) break;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── Cursor-based pagination (v2 endpoints) ────────────────────────
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Async generator for cursor-based paginated endpoints (v2).
|
|
243
|
+
* Follows _links.next URL with cursor parameter.
|
|
244
|
+
*
|
|
245
|
+
* @param {string} path Base path (e.g. /wiki/api/v2/spaces)
|
|
246
|
+
* @param {object} [params] Extra query params
|
|
247
|
+
* @param {number} [pageSize] Results per page (default: 25)
|
|
248
|
+
* @yields {object} Each item from the paginated results
|
|
249
|
+
*/
|
|
250
|
+
async function* paginateCursor(path, params = {}, pageSize = 25) {
|
|
251
|
+
const query = new URLSearchParams({
|
|
252
|
+
...params,
|
|
253
|
+
limit: String(pageSize),
|
|
254
|
+
});
|
|
255
|
+
let nextPath = `${path}?${query.toString()}`;
|
|
256
|
+
|
|
257
|
+
while (nextPath) {
|
|
258
|
+
const { data } = await get(nextPath);
|
|
259
|
+
|
|
260
|
+
const items = data.results || [];
|
|
261
|
+
for (const item of items) {
|
|
262
|
+
yield item;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (items.length === 0) break;
|
|
266
|
+
|
|
267
|
+
// v2 provides full next URL in _links.next - but it's a path, not absolute
|
|
268
|
+
if (data._links?.next) {
|
|
269
|
+
nextPath = data._links.next;
|
|
270
|
+
} else {
|
|
271
|
+
nextPath = null;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ── Public API ───────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
request,
|
|
280
|
+
get,
|
|
281
|
+
post,
|
|
282
|
+
put,
|
|
283
|
+
del,
|
|
284
|
+
paginateOffset,
|
|
285
|
+
paginateCursor,
|
|
286
|
+
getCredentials: () => creds(),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ── Utilities ────────────────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
function sleep(ms) {
|
|
293
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
294
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// auth.mjs - Verify Confluence credentials.
|
|
2
|
+
//
|
|
3
|
+
// Calls GET /wiki/api/v2/users/current (via v1 /wiki/rest/api/user/current)
|
|
4
|
+
// and prints the current user's details.
|
|
5
|
+
// Exit 0 on success, 2 on auth failure.
|
|
6
|
+
|
|
7
|
+
import { createClient, ConfluenceError } from '../client.mjs';
|
|
8
|
+
import { bold, green, red } from '../format.mjs';
|
|
9
|
+
|
|
10
|
+
const HELP = `confluence auth - Verify Confluence credentials
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
confluence auth
|
|
14
|
+
|
|
15
|
+
Checks the configured Confluence credentials by fetching the current user's
|
|
16
|
+
profile. Uses the same JIRA_* vault secrets (same Atlassian site).
|
|
17
|
+
Prints display name, email, account type, and user key.
|
|
18
|
+
`;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Run the auth command.
|
|
22
|
+
*
|
|
23
|
+
* @param {string[]} argv Remaining args after 'auth'
|
|
24
|
+
* @param {object} [deps] Dependency overrides
|
|
25
|
+
* @returns {Promise<number>} Exit code
|
|
26
|
+
*/
|
|
27
|
+
export async function run(argv = [], deps = {}) {
|
|
28
|
+
const log = deps.log || console.log;
|
|
29
|
+
const logError = deps.logError || console.error;
|
|
30
|
+
|
|
31
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
32
|
+
log(HELP);
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const client = deps.client || createClient();
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const { data } = await client.get('/wiki/rest/api/user/current');
|
|
40
|
+
|
|
41
|
+
log(green('Authentication successful.\n'));
|
|
42
|
+
log(` ${bold('Name:')} ${data.displayName || 'N/A'}`);
|
|
43
|
+
log(` ${bold('Email:')} ${data.email || 'N/A'}`);
|
|
44
|
+
log(` ${bold('Username:')} ${data.username || 'N/A'}`);
|
|
45
|
+
log(` ${bold('Account ID:')} ${data.accountId || 'N/A'}`);
|
|
46
|
+
log(` ${bold('Account Type:')} ${data.accountType || 'N/A'}`);
|
|
47
|
+
|
|
48
|
+
const creds = client.getCredentials();
|
|
49
|
+
log(` ${bold('Site:')} ${creds.domain}.atlassian.net`);
|
|
50
|
+
|
|
51
|
+
return 0;
|
|
52
|
+
} catch (err) {
|
|
53
|
+
if (err instanceof ConfluenceError) {
|
|
54
|
+
logError(red(`Authentication failed: ${err.message}`));
|
|
55
|
+
return err.exitCode;
|
|
56
|
+
}
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// comment.mjs - Add or list comments on a Confluence page.
|
|
2
|
+
//
|
|
3
|
+
// Usage:
|
|
4
|
+
// confluence comment <ID> <TEXT...> Add a comment
|
|
5
|
+
// confluence comment <ID> --list List comments
|
|
6
|
+
//
|
|
7
|
+
// Uses v1 endpoint for writing comments (v2 POST returns 405).
|
|
8
|
+
// Uses v2 endpoint for reading comments.
|
|
9
|
+
|
|
10
|
+
import { createClient, ConfluenceError } from '../client.mjs';
|
|
11
|
+
import { textToStorage, storageToText } from '../storage.mjs';
|
|
12
|
+
import { formatComment, bold, dim, green } from '../format.mjs';
|
|
13
|
+
|
|
14
|
+
const HELP = `confluence comment - Add or list comments on a page
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
confluence comment <ID> <TEXT> Add a comment to a page
|
|
18
|
+
confluence comment <ID> -- <TEXT> Add a comment (text after --)
|
|
19
|
+
confluence comment <ID> --list List recent comments
|
|
20
|
+
|
|
21
|
+
Options:
|
|
22
|
+
--list List comments instead of adding one
|
|
23
|
+
--help, -h Show this help
|
|
24
|
+
|
|
25
|
+
Comments are added via the v1 API (v2 POST returns 405).
|
|
26
|
+
Comment text is converted to Confluence storage format.
|
|
27
|
+
|
|
28
|
+
Examples:
|
|
29
|
+
confluence comment 12345 "This page needs updating."
|
|
30
|
+
confluence comment 12345 --list
|
|
31
|
+
`;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Parse comment command args.
|
|
35
|
+
* Handles -- separator, --list flag, and positional args.
|
|
36
|
+
*/
|
|
37
|
+
function parseCommentArgs(argv) {
|
|
38
|
+
let pageId = null;
|
|
39
|
+
let list = false;
|
|
40
|
+
const textParts = [];
|
|
41
|
+
let afterDash = false;
|
|
42
|
+
|
|
43
|
+
for (let i = 0; i < argv.length; i++) {
|
|
44
|
+
if (argv[i] === '--') {
|
|
45
|
+
afterDash = true;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (afterDash) {
|
|
49
|
+
textParts.push(argv[i]);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (argv[i] === '--list') {
|
|
53
|
+
list = true;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (argv[i] === '--help' || argv[i] === '-h') {
|
|
57
|
+
return { pageId: null, text: null, list: false, help: true };
|
|
58
|
+
}
|
|
59
|
+
if (!pageId) {
|
|
60
|
+
pageId = argv[i];
|
|
61
|
+
} else {
|
|
62
|
+
textParts.push(argv[i]);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const text = textParts.length > 0 ? textParts.join(' ') : null;
|
|
67
|
+
return { pageId, text, list, help: false };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Run the comment command.
|
|
72
|
+
*
|
|
73
|
+
* @param {string[]} argv Remaining args after 'comment'
|
|
74
|
+
* @param {object} [deps] Dependency overrides
|
|
75
|
+
* @returns {Promise<number>} Exit code
|
|
76
|
+
*/
|
|
77
|
+
export async function run(argv = [], deps = {}) {
|
|
78
|
+
const log = deps.log || console.log;
|
|
79
|
+
const logError = deps.logError || console.error;
|
|
80
|
+
|
|
81
|
+
const { pageId, text, list, help } = parseCommentArgs(argv);
|
|
82
|
+
|
|
83
|
+
if (help) {
|
|
84
|
+
log(HELP);
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!pageId) {
|
|
89
|
+
logError('Usage: confluence comment <ID> <TEXT>\n confluence comment <ID> --list');
|
|
90
|
+
return 1;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const client = deps.client || createClient();
|
|
94
|
+
|
|
95
|
+
if (list) {
|
|
96
|
+
return listComments(client, pageId, log, logError);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!text) {
|
|
100
|
+
logError('Usage: confluence comment <ID> <TEXT>\nProvide comment text as arguments or after --');
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return addComment(client, pageId, text, log, logError);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* List comments on a page (via v2 endpoint).
|
|
109
|
+
*/
|
|
110
|
+
async function listComments(client, pageId, log, logError) {
|
|
111
|
+
try {
|
|
112
|
+
const comments = [];
|
|
113
|
+
for await (const comment of client.paginateCursor(
|
|
114
|
+
`/wiki/api/v2/pages/${encodeURIComponent(pageId)}/footer-comments`,
|
|
115
|
+
{ 'body-format': 'storage' }
|
|
116
|
+
)) {
|
|
117
|
+
comments.push(comment);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (comments.length === 0) {
|
|
121
|
+
log(dim(`No comments on page ${pageId}.`));
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
log(bold(`Comments on page ${pageId}`) + dim(` (${comments.length})\n`));
|
|
126
|
+
|
|
127
|
+
for (const comment of comments) {
|
|
128
|
+
// Render storage body to text
|
|
129
|
+
const bodyStorage = comment.body?.storage?.value || '';
|
|
130
|
+
comment._bodyText = storageToText(bodyStorage);
|
|
131
|
+
log(formatComment(comment));
|
|
132
|
+
log('');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return 0;
|
|
136
|
+
} catch (err) {
|
|
137
|
+
if (err instanceof ConfluenceError) {
|
|
138
|
+
logError(`Failed to list comments: ${err.message}`);
|
|
139
|
+
return err.exitCode;
|
|
140
|
+
}
|
|
141
|
+
throw err;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Add a comment to a page (via v1 endpoint - v2 POST returns 405).
|
|
147
|
+
*/
|
|
148
|
+
async function addComment(client, pageId, text, log, logError) {
|
|
149
|
+
try {
|
|
150
|
+
const storageBody = textToStorage(text);
|
|
151
|
+
const { data } = await client.post('/wiki/rest/api/content', {
|
|
152
|
+
type: 'comment',
|
|
153
|
+
container: {
|
|
154
|
+
id: pageId,
|
|
155
|
+
type: 'page',
|
|
156
|
+
},
|
|
157
|
+
body: {
|
|
158
|
+
storage: {
|
|
159
|
+
value: storageBody,
|
|
160
|
+
representation: 'storage',
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
log(green(`Comment added to page ${pageId}.`) + dim(` (id: ${data.id})`));
|
|
166
|
+
return 0;
|
|
167
|
+
} catch (err) {
|
|
168
|
+
if (err instanceof ConfluenceError) {
|
|
169
|
+
logError(`Failed to add comment: ${err.message}`);
|
|
170
|
+
return err.exitCode;
|
|
171
|
+
}
|
|
172
|
+
throw err;
|
|
173
|
+
}
|
|
174
|
+
}
|