@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.
- package/README.md +12 -2
- package/bin/confluence +9 -0
- package/bin/confluence-fetch +9 -0
- package/bin/jira +9 -0
- package/dist/engineering-pa-core/knowledge/content-voice/banned-ai-tells-taxonomy.md +1 -1
- package/dist/engineering-pa-core/knowledge/knowledge-memory/staging-and-observations.md +3 -3
- 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 +240 -4
- package/dist/engineering-pa-scaffold/templates/CLAUDE.md.template +7 -7
- package/dist/engineering-pa-scaffold/templates/operator-README.md.template +4 -4
- package/dist/engineering-pa-scaffold/templates/root-CLAUDE.md.template +13 -0
- 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/dist/engineering-pa-workspace/app/home.html +7 -7
- package/dist/engineering-pa-workspace/app/onboarding.html +4 -4
- package/dist/engineering-pa-workspace/src/api.mjs +1 -1
- package/dist/engineering-pa-workspace/src/server.mjs +1 -1
- package/docs/installing.md +78 -0
- package/docs/onboarding-solo.md +162 -0
- package/docs/what-it-can-do.md +85 -0
- 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
|
+
}
|