@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.
Files changed (83) hide show
  1. package/README.md +5 -0
  2. package/bin/confluence +9 -0
  3. package/bin/confluence-fetch +9 -0
  4. package/bin/jira +9 -0
  5. package/dist/engineering-pa-core/knowledge/platform-tooling/workspace-extensions.md +62 -0
  6. package/dist/engineering-pa-core-extensions-atlassian/README.md +81 -16
  7. package/dist/engineering-pa-core-extensions-atlassian/bin/confluence +2 -0
  8. package/dist/engineering-pa-core-extensions-atlassian/bin/confluence-fetch +2 -0
  9. package/dist/engineering-pa-core-extensions-atlassian/bin/jira +2 -0
  10. package/dist/engineering-pa-core-extensions-atlassian/config/jira-workflow-chains.default.json +4 -0
  11. package/dist/engineering-pa-core-extensions-atlassian/index.mjs +17 -6
  12. package/dist/engineering-pa-core-extensions-atlassian/package.json +11 -4
  13. package/dist/engineering-pa-core-extensions-atlassian/src/common/invalidate-client.mjs +137 -0
  14. package/dist/engineering-pa-core-extensions-atlassian/src/common/pa-name.mjs +58 -0
  15. package/dist/engineering-pa-core-extensions-atlassian/src/common/secrets.mjs +158 -0
  16. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/cli.mjs +165 -0
  17. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/client.mjs +294 -0
  18. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/auth.mjs +59 -0
  19. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/comment.mjs +174 -0
  20. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/create.mjs +136 -0
  21. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/label.mjs +98 -0
  22. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mine.mjs +105 -0
  23. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mirror-folder.mjs +487 -0
  24. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mirror-page.mjs +351 -0
  25. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/page.mjs +127 -0
  26. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/pages.mjs +107 -0
  27. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/recent.mjs +107 -0
  28. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/search.mjs +123 -0
  29. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/spaces.mjs +82 -0
  30. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/test.mjs +155 -0
  31. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/tree.mjs +103 -0
  32. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/update.mjs +134 -0
  33. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/format.mjs +260 -0
  34. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/mirror.mjs +353 -0
  35. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/storage.mjs +880 -0
  36. package/dist/engineering-pa-core-extensions-atlassian/src/confluence-fetch/cli.mjs +313 -0
  37. package/dist/engineering-pa-core-extensions-atlassian/src/confluence-fetch/fetch.mjs +327 -0
  38. package/dist/engineering-pa-core-extensions-atlassian/src/jira/adf.mjs +388 -0
  39. package/dist/engineering-pa-core-extensions-atlassian/src/jira/bug-fields.mjs +294 -0
  40. package/dist/engineering-pa-core-extensions-atlassian/src/jira/cli.mjs +197 -0
  41. package/dist/engineering-pa-core-extensions-atlassian/src/jira/client.mjs +350 -0
  42. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/advance.mjs +486 -0
  43. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/attach.mjs +129 -0
  44. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/auth.mjs +53 -0
  45. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/boards.mjs +88 -0
  46. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/bulk-transition.mjs +223 -0
  47. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/cancel.mjs +135 -0
  48. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/comment.mjs +169 -0
  49. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/create.mjs +376 -0
  50. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/issue.mjs +105 -0
  51. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/link.mjs +204 -0
  52. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/mine.mjs +97 -0
  53. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/projects.mjs +91 -0
  54. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/search.mjs +80 -0
  55. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/sprint.mjs +111 -0
  56. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/sync-statuses.mjs +313 -0
  57. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/test.mjs +142 -0
  58. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/transition.mjs +197 -0
  59. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/update.mjs +284 -0
  60. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/worklog.mjs +380 -0
  61. package/dist/engineering-pa-core-extensions-atlassian/src/jira/format.mjs +267 -0
  62. package/dist/engineering-pa-core-extensions-atlassian/src/jira/invalidate.mjs +72 -0
  63. package/dist/engineering-pa-core-extensions-atlassian/src/jira/metadata.mjs +225 -0
  64. package/dist/engineering-pa-core-extensions-atlassian/src/jira/required-fields.mjs +334 -0
  65. package/dist/engineering-pa-core-extensions-atlassian/src/jira/workflow-chains.mjs +405 -0
  66. package/dist/engineering-pa-core-extensions-atlassian/src/jira/workflow-map.mjs +107 -0
  67. package/dist/engineering-pa-scaffold/README.md +32 -1
  68. package/dist/engineering-pa-scaffold/src/cli.mjs +83 -6
  69. package/dist/engineering-pa-scaffold/src/commands/init-workspace-extension.mjs +234 -0
  70. package/dist/engineering-pa-scaffold/src/commands/init.mjs +6 -5
  71. package/dist/engineering-pa-scaffold/src/generator.mjs +104 -2
  72. package/dist/engineering-pa-scaffold/templates/CLAUDE.md.template +5 -5
  73. package/dist/engineering-pa-types-engineering/index.mjs +14 -6
  74. package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/create-a-small-app.md +81 -0
  75. package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/for-agents.md +25 -0
  76. package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/index.md +34 -0
  77. package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/reference.md +16 -0
  78. package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/wire-your-agent.md +17 -0
  79. package/dist/engineering-pa-types-engineering/package.json +2 -2
  80. package/dist/engineering-pa-types-engineering/roles/rcf-lite-engineer/definition.md +183 -0
  81. package/docs/onboarding-solo.md +55 -4
  82. package/docs/what-it-can-do.md +85 -0
  83. 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
+ }