@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,313 @@
|
|
|
1
|
+
// cli.mjs - Confluence Fetch CLI.
|
|
2
|
+
//
|
|
3
|
+
// Single-command CLI that wraps the full retrieval pipeline:
|
|
4
|
+
// confluence-fetch <phrase> [options]
|
|
5
|
+
//
|
|
6
|
+
// Exit codes:
|
|
7
|
+
// 0 - success
|
|
8
|
+
// 1 - usage error (bad args)
|
|
9
|
+
// 2 - API/logical error (page not found, auth failed, no results)
|
|
10
|
+
// 3 - transport/IO failure (network error, vault unavailable)
|
|
11
|
+
|
|
12
|
+
import { retrieve, createConfluenceClient, searchPages } from './fetch.mjs';
|
|
13
|
+
import { resolve, dirname } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const REPO_ROOT =
|
|
18
|
+
process.env.PA_REPO_ROOT || process.env.DAVE_ROOT || process.cwd();
|
|
19
|
+
|
|
20
|
+
// ── Help text ────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
const HELP = `confluence-fetch - Search, fetch, save, and view Confluence pages
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
confluence-fetch <phrase> [options]
|
|
26
|
+
|
|
27
|
+
Arguments:
|
|
28
|
+
phrase Search phrase (title or content)
|
|
29
|
+
|
|
30
|
+
Options:
|
|
31
|
+
--max N Maximum search results (default: 5)
|
|
32
|
+
--pick N Auto-select Nth result, 1-based (default: 1)
|
|
33
|
+
--search-type TYPE Search strategy: auto, title, text (default: auto)
|
|
34
|
+
--list List search results without fetching
|
|
35
|
+
--viewer-base URL Workspace viewer base URL (default: http://127.0.0.1:4222)
|
|
36
|
+
--no-open Do not open the viewer URL after saving
|
|
37
|
+
--help, -h Show this help
|
|
38
|
+
|
|
39
|
+
Search strategy (--search-type):
|
|
40
|
+
auto Try title search first; fall back to full-text if no results (default)
|
|
41
|
+
title Search by title only
|
|
42
|
+
text Search by full-text content only
|
|
43
|
+
|
|
44
|
+
Examples:
|
|
45
|
+
confluence-fetch "observability standards"
|
|
46
|
+
confluence-fetch "API documentation" --max 10 --pick 2
|
|
47
|
+
confluence-fetch "deployment runbook" --search-type text
|
|
48
|
+
confluence-fetch "architecture" --list
|
|
49
|
+
`;
|
|
50
|
+
|
|
51
|
+
// ── Arg parser ───────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Parse argv into flags and positional args.
|
|
55
|
+
*
|
|
56
|
+
* @param {string[]} argv Arguments
|
|
57
|
+
* @returns {{ _: string[], [key: string]: string|boolean }}
|
|
58
|
+
*/
|
|
59
|
+
export function parseArgs(argv) {
|
|
60
|
+
const args = { _: [] };
|
|
61
|
+
for (let i = 0; i < argv.length; i++) {
|
|
62
|
+
if (argv[i] === '--') {
|
|
63
|
+
args._.push(...argv.slice(i + 1));
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
if (argv[i].startsWith('--')) {
|
|
67
|
+
const key = argv[i].slice(2);
|
|
68
|
+
if (key.startsWith('no-')) {
|
|
69
|
+
// --no-open → open = false
|
|
70
|
+
args[key.slice(3)] = false;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (i + 1 >= argv.length || argv[i + 1].startsWith('-')) {
|
|
74
|
+
args[key] = true;
|
|
75
|
+
} else {
|
|
76
|
+
args[key] = argv[++i];
|
|
77
|
+
}
|
|
78
|
+
} else if (argv[i].startsWith('-') && argv[i].length === 2) {
|
|
79
|
+
const key = argv[i].slice(1);
|
|
80
|
+
if (i + 1 >= argv.length || argv[i + 1].startsWith('-')) {
|
|
81
|
+
args[key] = true;
|
|
82
|
+
} else {
|
|
83
|
+
args[key] = argv[++i];
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
args._.push(argv[i]);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return args;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── ANSI helpers ─────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
const IS_TTY = process.stdout.isTTY;
|
|
95
|
+
function ansi(code, text) { return IS_TTY ? `\x1b[${code}m${text}\x1b[0m` : text; }
|
|
96
|
+
function bold(text) { return ansi('1', text); }
|
|
97
|
+
function dim(text) { return ansi('2', text); }
|
|
98
|
+
function green(text) { return ansi('32', text); }
|
|
99
|
+
function cyan(text) { return ansi('36', text); }
|
|
100
|
+
|
|
101
|
+
// ── Format search results ────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Format search results as a numbered list.
|
|
105
|
+
*
|
|
106
|
+
* @param {Array<{id: string, title: string, space: string}>} results
|
|
107
|
+
* @returns {string}
|
|
108
|
+
*/
|
|
109
|
+
function formatResults(results) {
|
|
110
|
+
if (!results || results.length === 0) return dim('No results found.');
|
|
111
|
+
|
|
112
|
+
const lines = results.map((r, i) => {
|
|
113
|
+
const num = String(i + 1).padStart(2);
|
|
114
|
+
const space = r.space ? dim(` [${r.space}]`) : '';
|
|
115
|
+
return ` ${dim(num + '.')} ${r.title || '(untitled)'}${space} ${dim('ID:' + r.id)}`;
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
return lines.join('\n');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Run ──────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Run the CLI.
|
|
125
|
+
*
|
|
126
|
+
* @param {string[]} argv Arguments after the command name
|
|
127
|
+
* @param {object} [deps] Dependency overrides for testing
|
|
128
|
+
* @returns {Promise<number>} Exit code
|
|
129
|
+
*/
|
|
130
|
+
export async function run(argv = [], deps = {}) {
|
|
131
|
+
const log = deps.log || console.log;
|
|
132
|
+
const logError = deps.logError || console.error;
|
|
133
|
+
const args = parseArgs(argv);
|
|
134
|
+
|
|
135
|
+
// Help
|
|
136
|
+
if (args.help || args.h) {
|
|
137
|
+
log(HELP);
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Phrase is required
|
|
142
|
+
const phrase = args._.join(' ');
|
|
143
|
+
if (!phrase) {
|
|
144
|
+
logError('Usage: confluence-fetch <phrase> [options]');
|
|
145
|
+
logError('Run with --help for full usage.');
|
|
146
|
+
return 1;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Parse options
|
|
150
|
+
const max = args.max ? parseInt(args.max, 10) : 5;
|
|
151
|
+
if (isNaN(max) || max < 1) {
|
|
152
|
+
logError('--max must be a positive integer');
|
|
153
|
+
return 1;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const pick = args.pick ? parseInt(args.pick, 10) : 1;
|
|
157
|
+
if (isNaN(pick) || pick < 1) {
|
|
158
|
+
logError('--pick must be a positive integer (1-based)');
|
|
159
|
+
return 1;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const searchType = args['search-type'] || 'auto';
|
|
163
|
+
if (!['auto', 'title', 'text'].includes(searchType)) {
|
|
164
|
+
logError('--search-type must be one of: auto, title, text');
|
|
165
|
+
return 1;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const listOnly = args.list === true;
|
|
169
|
+
const viewerBase = args['viewer-base'] || 'http://127.0.0.1:4222';
|
|
170
|
+
const shouldOpen = args.open !== false; // default true unless --no-open
|
|
171
|
+
|
|
172
|
+
// Create client
|
|
173
|
+
let client;
|
|
174
|
+
if (deps.client) {
|
|
175
|
+
client = deps.client;
|
|
176
|
+
} else {
|
|
177
|
+
try {
|
|
178
|
+
client = await createConfluenceClient();
|
|
179
|
+
} catch (err) {
|
|
180
|
+
logError(`Failed to initialise Confluence client: ${err.message}`);
|
|
181
|
+
return 3;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
// List mode - just search and display results
|
|
187
|
+
if (listOnly) {
|
|
188
|
+
log(bold('Searching Confluence: ') + dim(`"${phrase}"`));
|
|
189
|
+
log('');
|
|
190
|
+
|
|
191
|
+
const { results, searchType: usedType } = await searchPages(client, phrase, {
|
|
192
|
+
max,
|
|
193
|
+
searchType,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
log(dim(`Search type: ${usedType}`) + dim(` | ${results.length} result(s)`));
|
|
197
|
+
log('');
|
|
198
|
+
|
|
199
|
+
if (results.length === 0) {
|
|
200
|
+
log(dim('No results found.'));
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const formatted = results.map((r) => ({
|
|
205
|
+
id: r.id,
|
|
206
|
+
title: r.title || '(untitled)',
|
|
207
|
+
space: r.space?.key || '',
|
|
208
|
+
}));
|
|
209
|
+
|
|
210
|
+
log(formatResults(formatted));
|
|
211
|
+
log('');
|
|
212
|
+
log(dim('Use --pick N to fetch a specific result:'));
|
|
213
|
+
log(dim(` confluence-fetch "${phrase}" --pick N`));
|
|
214
|
+
return 0;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Full retrieval mode
|
|
218
|
+
log(bold('Fetching from Confluence: ') + dim(`"${phrase}"`));
|
|
219
|
+
log('');
|
|
220
|
+
|
|
221
|
+
const result = await retrieve(client, phrase, {
|
|
222
|
+
max,
|
|
223
|
+
pick,
|
|
224
|
+
searchType,
|
|
225
|
+
viewerBase,
|
|
226
|
+
repoRoot: deps.repoRoot || REPO_ROOT,
|
|
227
|
+
notesDir: deps.notesDir,
|
|
228
|
+
writeFileSync: deps.writeFileSync,
|
|
229
|
+
storageToMarkdown: deps.storageToMarkdown,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
if (result.resultCount === 0) {
|
|
233
|
+
log(dim('No results found for: ') + `"${phrase}"`);
|
|
234
|
+
log('');
|
|
235
|
+
log(dim('Tips:'));
|
|
236
|
+
log(dim(' - Try broader search terms'));
|
|
237
|
+
log(dim(' - Use --search-type text for full-text search'));
|
|
238
|
+
return 2;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Show what was found if multiple results
|
|
242
|
+
if (result.resultCount > 1) {
|
|
243
|
+
log(dim(`Found ${result.resultCount} result(s) via ${result.searchType} search. Fetching #${pick}:`));
|
|
244
|
+
log('');
|
|
245
|
+
log(formatResults(result.results));
|
|
246
|
+
log('');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Show the fetched page
|
|
250
|
+
log(green('Saved: ') + bold(result.title));
|
|
251
|
+
log(dim(' File: ') + result.filePath);
|
|
252
|
+
log(dim(' View: ') + cyan(result.viewerUrl));
|
|
253
|
+
|
|
254
|
+
// Open in browser
|
|
255
|
+
if (shouldOpen && !deps.writeFileSync) {
|
|
256
|
+
const openUrl = deps.openUrl || defaultOpenUrl;
|
|
257
|
+
await openUrl(result.viewerUrl);
|
|
258
|
+
log('');
|
|
259
|
+
log(dim('Opened in browser.'));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return 0;
|
|
263
|
+
} catch (err) {
|
|
264
|
+
// Check for ConfluenceError-shaped errors
|
|
265
|
+
if (err.name === 'ConfluenceError' || err.exitCode) {
|
|
266
|
+
logError(`Confluence error: ${err.message}`);
|
|
267
|
+
return err.exitCode || 2;
|
|
268
|
+
}
|
|
269
|
+
logError(`Error: ${err.message}`);
|
|
270
|
+
return 3;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── Open URL ─────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Open a URL in the default browser.
|
|
278
|
+
* @param {string} url
|
|
279
|
+
*/
|
|
280
|
+
async function defaultOpenUrl(url) {
|
|
281
|
+
const { execFile } = await import('node:child_process');
|
|
282
|
+
const { promisify } = await import('node:util');
|
|
283
|
+
const execFileAsync = promisify(execFile);
|
|
284
|
+
|
|
285
|
+
const platform = process.platform;
|
|
286
|
+
if (platform === 'darwin') {
|
|
287
|
+
await execFileAsync('open', [url]);
|
|
288
|
+
} else if (platform === 'win32') {
|
|
289
|
+
await execFileAsync('cmd', ['/c', 'start', '', url]);
|
|
290
|
+
} else {
|
|
291
|
+
await execFileAsync('xdg-open', [url]);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ── Entry point ──────────────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* CLI entry point. Uses `process.exitCode` (not `process.exit`) so pending
|
|
299
|
+
* piped-stdout writes drain before Node exits - exit-before-drain
|
|
300
|
+
* truncates at the first pipe-buffer boundary (~8KB on macOS). Same class
|
|
301
|
+
* as w-2026-08-31-dave-039.
|
|
302
|
+
*
|
|
303
|
+
* @param {string[]} argv process.argv.slice(2)
|
|
304
|
+
*/
|
|
305
|
+
export async function main(argv) {
|
|
306
|
+
try {
|
|
307
|
+
const code = await run(argv);
|
|
308
|
+
process.exitCode = code ?? 0;
|
|
309
|
+
} catch (err) {
|
|
310
|
+
console.error(`confluence-fetch: ${err.message || err}`);
|
|
311
|
+
process.exitCode = 3;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// fetch.mjs - Core Confluence page retrieval logic.
|
|
2
|
+
//
|
|
3
|
+
// Orchestrates the full retrieval flow:
|
|
4
|
+
// 1. Search Confluence by title (falls back to full-text)
|
|
5
|
+
// 2. Select the best match (or let the caller choose)
|
|
6
|
+
// 3. Fetch the page content as markdown
|
|
7
|
+
// 4. Save to operator/notes/
|
|
8
|
+
// 5. Return the viewer URL
|
|
9
|
+
//
|
|
10
|
+
// Delegates to the Confluence tool via its public entry point
|
|
11
|
+
// (operator/extensions/tools/confluence/index.mjs), never its src/
|
|
12
|
+
// internals, which may be reorganised without notice.
|
|
13
|
+
// Zero external dependencies - uses only Node built-ins.
|
|
14
|
+
|
|
15
|
+
import { resolve, dirname } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { mkdirSync, writeFileSync as nodeWriteFileSync, readFileSync } from 'node:fs';
|
|
18
|
+
|
|
19
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
|
|
21
|
+
// ── Path resolution ──────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
// Repo root: env override, else cwd. Invoked from a workspace root.
|
|
24
|
+
const REPO_ROOT =
|
|
25
|
+
process.env.PA_REPO_ROOT || process.env.DAVE_ROOT || process.cwd();
|
|
26
|
+
|
|
27
|
+
// Public entry point of the sibling Confluence tool. Stable contract:
|
|
28
|
+
// see operator/extensions/tools/confluence/README.md "Library usage".
|
|
29
|
+
// Resolved relative to this module, so it survives repo relocation.
|
|
30
|
+
const CONFLUENCE_ENTRY = new URL('../confluence/storage.mjs', import.meta.url);
|
|
31
|
+
|
|
32
|
+
// Default notes directory for saved pages
|
|
33
|
+
const NOTES_DIR = resolve(REPO_ROOT, 'operator', 'notes');
|
|
34
|
+
|
|
35
|
+
// Read the workspace port from the merged operator config - the device-local
|
|
36
|
+
// preferences.local.json overlay over the synced preferences.json - falling
|
|
37
|
+
// back to the platform default (4200).
|
|
38
|
+
//
|
|
39
|
+
// The overlay (preferences.local.json) is the operator's device-scoped
|
|
40
|
+
// escape hatch: gitignored, so a per-device workspacePort never travels
|
|
41
|
+
// through the synced base file. Precedence matches the platform's own
|
|
42
|
+
// reader in engineering-pa-core src/desk/team-scope.mjs mergePreferences().
|
|
43
|
+
const PREFERENCES_PATH = resolve(REPO_ROOT, 'operator', 'config', 'preferences.json');
|
|
44
|
+
const PREFERENCES_LOCAL_PATH = resolve(REPO_ROOT, 'operator', 'config', 'preferences.local.json');
|
|
45
|
+
const PLATFORM_DEFAULT_PORT = 4200;
|
|
46
|
+
|
|
47
|
+
function readJsonObject(path) {
|
|
48
|
+
try {
|
|
49
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
50
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
51
|
+
} catch {
|
|
52
|
+
// Missing or malformed - that layer contributes nothing.
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolve the local workspace port from the merged preferences.
|
|
59
|
+
* Overlay keys win; keys it omits fall through to the synced base; a device
|
|
60
|
+
* with no overlay behaves exactly as before this change.
|
|
61
|
+
*
|
|
62
|
+
* Paths are injectable for tests only - production callers pass nothing.
|
|
63
|
+
*
|
|
64
|
+
* @param {object} [paths]
|
|
65
|
+
* @param {string} [paths.base] preferences.json path.
|
|
66
|
+
* @param {string} [paths.local] preferences.local.json path.
|
|
67
|
+
* @returns {number}
|
|
68
|
+
*/
|
|
69
|
+
export function resolveWorkspacePort({ base = PREFERENCES_PATH, local = PREFERENCES_LOCAL_PATH } = {}) {
|
|
70
|
+
const prefs = { ...(readJsonObject(base) || {}), ...(readJsonObject(local) || {}) };
|
|
71
|
+
return typeof prefs.workspacePort === 'number' ? prefs.workspacePort : PLATFORM_DEFAULT_PORT;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const DEFAULT_VIEWER_BASE = `http://127.0.0.1:${resolveWorkspacePort()}`;
|
|
75
|
+
|
|
76
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Sanitise a page title for use as a filename.
|
|
80
|
+
* Converts to lowercase, replaces non-alphanumeric chars with hyphens,
|
|
81
|
+
* collapses consecutive hyphens, trims leading/trailing hyphens.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} title Page title
|
|
84
|
+
* @returns {string} Sanitised filename stem
|
|
85
|
+
*/
|
|
86
|
+
export function sanitiseTitle(title) {
|
|
87
|
+
if (!title) return 'untitled';
|
|
88
|
+
return title
|
|
89
|
+
.toLowerCase()
|
|
90
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
91
|
+
.replace(/-{2,}/g, '-')
|
|
92
|
+
.replace(/^-|-$/g, '')
|
|
93
|
+
|| 'untitled';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Build the viewer URL for a saved file.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} filePath Absolute path to the saved file
|
|
100
|
+
* @param {string} repoRoot Absolute path to the repo root
|
|
101
|
+
* @param {string} viewerBase Base URL for the workspace viewer
|
|
102
|
+
* @returns {string} Full viewer URL
|
|
103
|
+
*/
|
|
104
|
+
export function buildViewerUrl(filePath, repoRoot, viewerBase) {
|
|
105
|
+
// The viewer expects a repo-relative path
|
|
106
|
+
const relative = filePath.startsWith(repoRoot)
|
|
107
|
+
? filePath.slice(repoRoot.length + 1)
|
|
108
|
+
: filePath;
|
|
109
|
+
return `${viewerBase}/viewer.html?file=${encodeURIComponent(relative)}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Search ───────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Search Confluence for pages matching a phrase.
|
|
116
|
+
* Tries title search first; falls back to full-text if no results.
|
|
117
|
+
*
|
|
118
|
+
* @param {object} client Confluence API client
|
|
119
|
+
* @param {string} phrase Search phrase
|
|
120
|
+
* @param {object} [options]
|
|
121
|
+
* @param {number} [options.max] Max results (default: 5)
|
|
122
|
+
* @param {string} [options.searchType] 'title', 'text', or 'auto' (default: 'auto')
|
|
123
|
+
* @returns {Promise<{ results: Array, searchType: string }>}
|
|
124
|
+
*/
|
|
125
|
+
export async function searchPages(client, phrase, options = {}) {
|
|
126
|
+
const max = options.max || 5;
|
|
127
|
+
const searchType = options.searchType || 'auto';
|
|
128
|
+
|
|
129
|
+
// Build CQL for title search
|
|
130
|
+
const titleCql = `title ~ '${escapeCql(phrase)}' AND type = page`;
|
|
131
|
+
// Build CQL for full-text search
|
|
132
|
+
const textCql = `text ~ '${escapeCql(phrase)}' AND type = page`;
|
|
133
|
+
|
|
134
|
+
async function runSearch(cql, limit) {
|
|
135
|
+
const results = [];
|
|
136
|
+
for await (const item of client.paginateOffset('/wiki/rest/api/content/search', { cql, limit: String(limit) }, limit)) {
|
|
137
|
+
results.push(item);
|
|
138
|
+
if (results.length >= limit) break;
|
|
139
|
+
}
|
|
140
|
+
return results;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (searchType === 'text') {
|
|
144
|
+
const results = await runSearch(textCql, max);
|
|
145
|
+
return { results, searchType: 'text' };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (searchType === 'title') {
|
|
149
|
+
const results = await runSearch(titleCql, max);
|
|
150
|
+
return { results, searchType: 'title' };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Auto mode: try title first, fall back to text
|
|
154
|
+
const titleResults = await runSearch(titleCql, max);
|
|
155
|
+
if (titleResults.length > 0) {
|
|
156
|
+
return { results: titleResults, searchType: 'title' };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const textResults = await runSearch(textCql, max);
|
|
160
|
+
return { results: textResults, searchType: 'text' };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Escape single quotes in CQL values.
|
|
165
|
+
* @param {string} value
|
|
166
|
+
* @returns {string}
|
|
167
|
+
*/
|
|
168
|
+
function escapeCql(value) {
|
|
169
|
+
return value.replace(/'/g, "\\'");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── Fetch page ───────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Fetch a single Confluence page and convert to markdown.
|
|
176
|
+
*
|
|
177
|
+
* @param {object} client Confluence API client
|
|
178
|
+
* @param {string} pageId Page ID
|
|
179
|
+
* @param {object} [deps] Dependency overrides for testing
|
|
180
|
+
* @param {Function} [deps.storageToMarkdown] Storage converter override
|
|
181
|
+
* @returns {Promise<{ title: string, markdown: string, pageId: string, spaceId: string }>}
|
|
182
|
+
*/
|
|
183
|
+
export async function fetchPage(client, pageId, deps = {}) {
|
|
184
|
+
const { data: page } = await client.get(
|
|
185
|
+
`/wiki/api/v2/pages/${encodeURIComponent(pageId)}?body-format=storage`
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const storageBody = page.body?.storage?.value || '';
|
|
189
|
+
|
|
190
|
+
// Load storage converter
|
|
191
|
+
let storageToMarkdown;
|
|
192
|
+
if (deps.storageToMarkdown) {
|
|
193
|
+
storageToMarkdown = deps.storageToMarkdown;
|
|
194
|
+
} else {
|
|
195
|
+
const confluence = await import(CONFLUENCE_ENTRY);
|
|
196
|
+
storageToMarkdown = confluence.storageToMarkdown;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const markdown = storageToMarkdown(storageBody);
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
title: page.title || 'Untitled',
|
|
203
|
+
markdown,
|
|
204
|
+
pageId: page.id,
|
|
205
|
+
spaceId: page.spaceId || null,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── Save ─────────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Save markdown content to operator/notes/.
|
|
213
|
+
*
|
|
214
|
+
* @param {string} title Page title (used to generate filename)
|
|
215
|
+
* @param {string} markdown Markdown content
|
|
216
|
+
* @param {object} [options]
|
|
217
|
+
* @param {string} [options.notesDir] Override notes directory
|
|
218
|
+
* @param {string} [options.filename] Override filename (without extension)
|
|
219
|
+
* @param {Function} [options.writeFileSync] Write function override for testing
|
|
220
|
+
* @returns {string} Absolute path to the saved file
|
|
221
|
+
*/
|
|
222
|
+
export function savePage(title, markdown, options = {}) {
|
|
223
|
+
const notesDir = options.notesDir || NOTES_DIR;
|
|
224
|
+
const stem = options.filename || `confluence-${sanitiseTitle(title)}`;
|
|
225
|
+
const filePath = resolve(notesDir, `${stem}.md`);
|
|
226
|
+
|
|
227
|
+
// Prepend a metadata header
|
|
228
|
+
const header = `# ${title}\n\n*Retrieved from Confluence on ${new Date().toISOString().slice(0, 10)}*\n\n---\n\n`;
|
|
229
|
+
const content = header + markdown;
|
|
230
|
+
|
|
231
|
+
const writeFn = options.writeFileSync || nodeWriteFileSync;
|
|
232
|
+
|
|
233
|
+
// Ensure the directory exists
|
|
234
|
+
if (!options.writeFileSync) {
|
|
235
|
+
mkdirSync(notesDir, { recursive: true });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
writeFn(filePath, content, 'utf-8');
|
|
239
|
+
return filePath;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── Full retrieval flow ─────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Execute the full retrieval pipeline:
|
|
246
|
+
* search -> select -> fetch -> save -> return viewer URL
|
|
247
|
+
*
|
|
248
|
+
* @param {object} client Confluence API client
|
|
249
|
+
* @param {string} phrase Search phrase
|
|
250
|
+
* @param {object} [options]
|
|
251
|
+
* @param {number} [options.max] Max search results (default: 5)
|
|
252
|
+
* @param {number} [options.pick] 1-based index to auto-pick from results
|
|
253
|
+
* @param {string} [options.searchType] 'title', 'text', or 'auto'
|
|
254
|
+
* @param {string} [options.notesDir] Override notes directory
|
|
255
|
+
* @param {string} [options.viewerBase] Override viewer base URL
|
|
256
|
+
* @param {string} [options.repoRoot] Override repo root path
|
|
257
|
+
* @param {Function} [options.writeFileSync] Override for testing
|
|
258
|
+
* @param {Function} [options.storageToMarkdown] Override for testing
|
|
259
|
+
* @returns {Promise<{ filePath: string, viewerUrl: string, title: string, pageId: string, searchType: string, resultCount: number }>}
|
|
260
|
+
*/
|
|
261
|
+
export async function retrieve(client, phrase, options = {}) {
|
|
262
|
+
const repoRoot = options.repoRoot || REPO_ROOT;
|
|
263
|
+
const viewerBase = options.viewerBase || DEFAULT_VIEWER_BASE;
|
|
264
|
+
const pick = options.pick || 1;
|
|
265
|
+
|
|
266
|
+
// Step 1: Search
|
|
267
|
+
const { results, searchType } = await searchPages(client, phrase, {
|
|
268
|
+
max: options.max,
|
|
269
|
+
searchType: options.searchType,
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
if (results.length === 0) {
|
|
273
|
+
return {
|
|
274
|
+
filePath: null,
|
|
275
|
+
viewerUrl: null,
|
|
276
|
+
title: null,
|
|
277
|
+
pageId: null,
|
|
278
|
+
searchType,
|
|
279
|
+
resultCount: 0,
|
|
280
|
+
results: [],
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Step 2: Select
|
|
285
|
+
const index = Math.max(0, Math.min(pick - 1, results.length - 1));
|
|
286
|
+
const selected = results[index];
|
|
287
|
+
|
|
288
|
+
// Step 3: Fetch
|
|
289
|
+
const page = await fetchPage(client, selected.id, {
|
|
290
|
+
storageToMarkdown: options.storageToMarkdown,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// Step 4: Save
|
|
294
|
+
const filePath = savePage(page.title, page.markdown, {
|
|
295
|
+
notesDir: options.notesDir || resolve(repoRoot, 'operator', 'notes'),
|
|
296
|
+
writeFileSync: options.writeFileSync,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// Step 5: Build viewer URL
|
|
300
|
+
const viewerUrl = buildViewerUrl(filePath, repoRoot, viewerBase);
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
filePath,
|
|
304
|
+
viewerUrl,
|
|
305
|
+
title: page.title,
|
|
306
|
+
pageId: page.pageId,
|
|
307
|
+
searchType,
|
|
308
|
+
resultCount: results.length,
|
|
309
|
+
results: results.map((r) => ({
|
|
310
|
+
id: r.id,
|
|
311
|
+
title: r.title || '',
|
|
312
|
+
space: r.space?.key || '',
|
|
313
|
+
})),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ── Exports for the Confluence client loader ────────────────────────
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Create a Confluence client via the Confluence tool's public entry.
|
|
321
|
+
* @param {object} [options] Client options (see the confluence README)
|
|
322
|
+
* @returns {object} Confluence API client
|
|
323
|
+
*/
|
|
324
|
+
export async function createConfluenceClient(options = {}) {
|
|
325
|
+
const { createClient } = await import(CONFLUENCE_ENTRY);
|
|
326
|
+
return createClient(options);
|
|
327
|
+
}
|