@stravica-ai/digital-operator-pa 0.1.39 → 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 (92) hide show
  1. package/README.md +12 -2
  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/content-voice/banned-ai-tells-taxonomy.md +1 -1
  6. package/dist/engineering-pa-core/knowledge/knowledge-memory/staging-and-observations.md +3 -3
  7. package/dist/engineering-pa-core/knowledge/platform-tooling/workspace-extensions.md +62 -0
  8. package/dist/engineering-pa-core-extensions-atlassian/README.md +81 -16
  9. package/dist/engineering-pa-core-extensions-atlassian/bin/confluence +2 -0
  10. package/dist/engineering-pa-core-extensions-atlassian/bin/confluence-fetch +2 -0
  11. package/dist/engineering-pa-core-extensions-atlassian/bin/jira +2 -0
  12. package/dist/engineering-pa-core-extensions-atlassian/config/jira-workflow-chains.default.json +4 -0
  13. package/dist/engineering-pa-core-extensions-atlassian/index.mjs +17 -6
  14. package/dist/engineering-pa-core-extensions-atlassian/package.json +11 -4
  15. package/dist/engineering-pa-core-extensions-atlassian/src/common/invalidate-client.mjs +137 -0
  16. package/dist/engineering-pa-core-extensions-atlassian/src/common/pa-name.mjs +58 -0
  17. package/dist/engineering-pa-core-extensions-atlassian/src/common/secrets.mjs +158 -0
  18. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/cli.mjs +165 -0
  19. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/client.mjs +294 -0
  20. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/auth.mjs +59 -0
  21. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/comment.mjs +174 -0
  22. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/create.mjs +136 -0
  23. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/label.mjs +98 -0
  24. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mine.mjs +105 -0
  25. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mirror-folder.mjs +487 -0
  26. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/mirror-page.mjs +351 -0
  27. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/page.mjs +127 -0
  28. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/pages.mjs +107 -0
  29. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/recent.mjs +107 -0
  30. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/search.mjs +123 -0
  31. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/spaces.mjs +82 -0
  32. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/test.mjs +155 -0
  33. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/tree.mjs +103 -0
  34. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/commands/update.mjs +134 -0
  35. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/format.mjs +260 -0
  36. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/mirror.mjs +353 -0
  37. package/dist/engineering-pa-core-extensions-atlassian/src/confluence/storage.mjs +880 -0
  38. package/dist/engineering-pa-core-extensions-atlassian/src/confluence-fetch/cli.mjs +313 -0
  39. package/dist/engineering-pa-core-extensions-atlassian/src/confluence-fetch/fetch.mjs +327 -0
  40. package/dist/engineering-pa-core-extensions-atlassian/src/jira/adf.mjs +388 -0
  41. package/dist/engineering-pa-core-extensions-atlassian/src/jira/bug-fields.mjs +294 -0
  42. package/dist/engineering-pa-core-extensions-atlassian/src/jira/cli.mjs +197 -0
  43. package/dist/engineering-pa-core-extensions-atlassian/src/jira/client.mjs +350 -0
  44. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/advance.mjs +486 -0
  45. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/attach.mjs +129 -0
  46. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/auth.mjs +53 -0
  47. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/boards.mjs +88 -0
  48. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/bulk-transition.mjs +223 -0
  49. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/cancel.mjs +135 -0
  50. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/comment.mjs +169 -0
  51. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/create.mjs +376 -0
  52. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/issue.mjs +105 -0
  53. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/link.mjs +204 -0
  54. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/mine.mjs +97 -0
  55. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/projects.mjs +91 -0
  56. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/search.mjs +80 -0
  57. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/sprint.mjs +111 -0
  58. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/sync-statuses.mjs +313 -0
  59. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/test.mjs +142 -0
  60. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/transition.mjs +197 -0
  61. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/update.mjs +284 -0
  62. package/dist/engineering-pa-core-extensions-atlassian/src/jira/commands/worklog.mjs +380 -0
  63. package/dist/engineering-pa-core-extensions-atlassian/src/jira/format.mjs +267 -0
  64. package/dist/engineering-pa-core-extensions-atlassian/src/jira/invalidate.mjs +72 -0
  65. package/dist/engineering-pa-core-extensions-atlassian/src/jira/metadata.mjs +225 -0
  66. package/dist/engineering-pa-core-extensions-atlassian/src/jira/required-fields.mjs +334 -0
  67. package/dist/engineering-pa-core-extensions-atlassian/src/jira/workflow-chains.mjs +405 -0
  68. package/dist/engineering-pa-core-extensions-atlassian/src/jira/workflow-map.mjs +107 -0
  69. package/dist/engineering-pa-scaffold/README.md +32 -1
  70. package/dist/engineering-pa-scaffold/src/cli.mjs +83 -6
  71. package/dist/engineering-pa-scaffold/src/commands/init-workspace-extension.mjs +234 -0
  72. package/dist/engineering-pa-scaffold/src/commands/init.mjs +6 -5
  73. package/dist/engineering-pa-scaffold/src/generator.mjs +240 -4
  74. package/dist/engineering-pa-scaffold/templates/CLAUDE.md.template +7 -7
  75. package/dist/engineering-pa-scaffold/templates/operator-README.md.template +4 -4
  76. package/dist/engineering-pa-scaffold/templates/root-CLAUDE.md.template +13 -0
  77. package/dist/engineering-pa-types-engineering/index.mjs +14 -6
  78. package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/create-a-small-app.md +81 -0
  79. package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/for-agents.md +25 -0
  80. package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/index.md +34 -0
  81. package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/reference.md +16 -0
  82. package/dist/engineering-pa-types-engineering/knowledge/rcf-lite/wire-your-agent.md +17 -0
  83. package/dist/engineering-pa-types-engineering/package.json +2 -2
  84. package/dist/engineering-pa-types-engineering/roles/rcf-lite-engineer/definition.md +183 -0
  85. package/dist/engineering-pa-workspace/app/home.html +7 -7
  86. package/dist/engineering-pa-workspace/app/onboarding.html +4 -4
  87. package/dist/engineering-pa-workspace/src/api.mjs +1 -1
  88. package/dist/engineering-pa-workspace/src/server.mjs +1 -1
  89. package/docs/installing.md +78 -0
  90. package/docs/onboarding-solo.md +162 -0
  91. package/docs/what-it-can-do.md +85 -0
  92. package/package.json +6 -2
@@ -0,0 +1,158 @@
1
+ // secrets.mjs - resolve Atlassian credentials for the jira and confluence
2
+ // CLIs (and the shared client factories in src/jira, src/confluence).
3
+ //
4
+ // Both CLIs point at the same Atlassian site so the three required keys are
5
+ // shared:
6
+ //
7
+ // JIRA_DOMAIN Atlassian subdomain, e.g. "yoursite" for yoursite.atlassian.net
8
+ // JIRA_EMAIL Account email used with the API token
9
+ // JIRA_API_TOKEN User-scoped API token from id.atlassian.com
10
+ //
11
+ // One optional key is also resolved on demand (never required):
12
+ //
13
+ // JIRA_DEFAULT_PROJECT Project key the `jira mine` verb narrows to when
14
+ // the operator does not pass --project / --all. With
15
+ // nothing configured, `mine` returns issues across
16
+ // every project the account can see.
17
+ //
18
+ // Resolution order for each key:
19
+ // 1. Explicit override via opts.secretsGet(key) (tests inject a function)
20
+ // 2. Environment variable of the same name (JIRA_DOMAIN, ...)
21
+ // 3. Platform `secrets` bin: `secrets get <ATLASSIAN_SECRETS_PREFIX?>KEY`
22
+ // Runs against the workspace-root vault the operator scaffold seeds.
23
+ // A prefix such as an org-slug like `acme/` (env var
24
+ // ATLASSIAN_SECRETS_PREFIX) picks a non-default scope via the platform
25
+ // full-scope reference form.
26
+ //
27
+ // The tool never prints or logs the values. All read paths run
28
+ // `stdio: ['ignore', 'pipe', 'pipe']` and the caller's error strings never
29
+ // interpolate the value.
30
+ //
31
+ // Zero npm dependencies. Uses node:child_process for the fallback bin call.
32
+
33
+ import { execFileSync } from 'node:child_process';
34
+
35
+ /**
36
+ * Sentinel raised when a required credential cannot be resolved. Callers
37
+ * wrap this in their own JiraError / ConfluenceError with exit code 3.
38
+ */
39
+ export class CredentialResolutionError extends Error {
40
+ constructor(message) {
41
+ super(message);
42
+ this.name = 'CredentialResolutionError';
43
+ }
44
+ }
45
+
46
+ const KEYS = ['JIRA_DOMAIN', 'JIRA_EMAIL', 'JIRA_API_TOKEN'];
47
+
48
+ /**
49
+ * Resolve a single Atlassian secret. Not for external use; the CLIs call
50
+ * loadAtlassianCredentials.
51
+ * @param {string} key
52
+ * @param {{ secretsGet?: (key: string) => string, secretsBin?: string, env?: Record<string,string> }} opts
53
+ * @returns {string}
54
+ */
55
+ export function resolveSecret(key, opts = {}) {
56
+ if (typeof opts.secretsGet === 'function') {
57
+ const v = opts.secretsGet(key);
58
+ if (typeof v === 'string' && v.length > 0) return v.trim();
59
+ throw new CredentialResolutionError(`opts.secretsGet returned no value for ${key}`);
60
+ }
61
+
62
+ const env = opts.env ?? process.env;
63
+
64
+ const envValue = env[key];
65
+ if (typeof envValue === 'string' && envValue.length > 0) return envValue.trim();
66
+
67
+ const prefix = env.ATLASSIAN_SECRETS_PREFIX ?? '';
68
+ const scopedKey = `${prefix}${key}`;
69
+ const bin = opts.secretsBin ?? 'secrets';
70
+
71
+ try {
72
+ const out = execFileSync(bin, ['get', scopedKey], {
73
+ encoding: 'utf8',
74
+ stdio: ['ignore', 'pipe', 'pipe'],
75
+ env,
76
+ });
77
+ const trimmed = out.trim();
78
+ if (trimmed.length === 0) {
79
+ throw new CredentialResolutionError(
80
+ `platform \`secrets get ${scopedKey}\` returned empty output`,
81
+ );
82
+ }
83
+ return trimmed;
84
+ } catch (err) {
85
+ if (err instanceof CredentialResolutionError) throw err;
86
+ const stderrSnippet = err.stderr ? String(err.stderr).trim().split('\n').pop() : '';
87
+ const hint = stderrSnippet
88
+ ? ` (${stderrSnippet})`
89
+ : ' (bin missing? try `pnpm exec secrets get` from your operator repo)';
90
+ throw new CredentialResolutionError(
91
+ `could not read ${key} from env or \`secrets get ${scopedKey}\`${hint}`,
92
+ );
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Resolve an OPTIONAL Atlassian setting. Same env -> secrets bin order as
98
+ * resolveSecret, but returns null instead of throwing when the key is
99
+ * absent so a caller can fall back to a "no default" branch cleanly. Used
100
+ * for JIRA_DEFAULT_PROJECT and any other future soft config the tools
101
+ * accept but never require.
102
+ *
103
+ * A missing env var and a bin call that exits non-zero, both return null;
104
+ * an empty string from the bin returns null too. Any other exec failure
105
+ * (bin missing, permission error) is swallowed so an operator without the
106
+ * `secrets` bin on PATH still gets the "no default" behaviour rather than
107
+ * a crash on an optional lookup. The primary credential path stays strict.
108
+ *
109
+ * @param {string} key
110
+ * @param {{ secretsGet?: (key: string) => string, secretsBin?: string, env?: Record<string,string> }} opts
111
+ * @returns {string | null}
112
+ */
113
+ export function resolveOptionalSecret(key, opts = {}) {
114
+ if (typeof opts.secretsGet === 'function') {
115
+ try {
116
+ const v = opts.secretsGet(key);
117
+ if (typeof v === 'string' && v.length > 0) return v.trim();
118
+ return null;
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ const env = opts.env ?? process.env;
125
+
126
+ const envValue = env[key];
127
+ if (typeof envValue === 'string' && envValue.length > 0) return envValue.trim();
128
+
129
+ const prefix = env.ATLASSIAN_SECRETS_PREFIX ?? '';
130
+ const scopedKey = `${prefix}${key}`;
131
+ const bin = opts.secretsBin ?? 'secrets';
132
+
133
+ try {
134
+ const out = execFileSync(bin, ['get', scopedKey], {
135
+ encoding: 'utf8',
136
+ stdio: ['ignore', 'pipe', 'pipe'],
137
+ env,
138
+ });
139
+ const trimmed = out.trim();
140
+ return trimmed.length > 0 ? trimmed : null;
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Load all three Atlassian credentials. Test suites pass in
148
+ * `{ secretsGet: (key) => literal }` to skip both env vars and the bin.
149
+ *
150
+ * @param {{ secretsGet?: (key: string) => string, secretsBin?: string, env?: Record<string,string> }} opts
151
+ * @returns {{ domain: string, email: string, apiToken: string }}
152
+ */
153
+ export function loadAtlassianCredentials(opts = {}) {
154
+ const domain = resolveSecret('JIRA_DOMAIN', opts);
155
+ const email = resolveSecret('JIRA_EMAIL', opts);
156
+ const apiToken = resolveSecret('JIRA_API_TOKEN', opts);
157
+ return { domain, email, apiToken };
158
+ }
@@ -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
+ }