@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,487 @@
1
+ // mirror-folder.mjs - Mirror a Confluence folder subtree to local markdown.
2
+ //
3
+ // Usage:
4
+ // confluence mirror-folder <FOLDER_ID> [--target DIR] [--dry-run] [--no-reindex]
5
+ //
6
+ // Behaviour:
7
+ // 1. Fetch the folder's descendants (folders + pages, recursively).
8
+ // 2. For each page, fetch the storage-format body and convert to markdown.
9
+ // 3. Write every page into a staging directory that mirrors the Confluence
10
+ // hierarchy.
11
+ // 4. Atomically swap the staging directory into the target: preserve
12
+ // README.md and any dotfiles, remove everything else, move staged
13
+ // content in.
14
+ // 5. Run `npm run reindex` against the repo root so domains.json refreshes.
15
+ //
16
+ // Design notes:
17
+ // - A partial/failed run must never leave the target directory broken.
18
+ // All page fetches happen into staging first; the swap is last.
19
+ // - Zero-deps: everything uses node: built-ins and the existing client.
20
+ // - Progress goes to stderr (console.error) so --dry-run output on stdout
21
+ // can be piped cleanly.
22
+
23
+ import { mkdir, writeFile, rm, readdir, rename, stat, readFile } from 'node:fs/promises';
24
+ import { basename, join, resolve, dirname } from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+ import { spawnSync } from 'node:child_process';
27
+
28
+ import { createClient, ConfluenceError } from '../client.mjs';
29
+ import { storageToMarkdown } from '../storage.mjs';
30
+ import {
31
+ sanitiseTitle,
32
+ disambiguate,
33
+ composeMarkdown,
34
+ planTree,
35
+ splitForSwap,
36
+ pageUrl,
37
+ buildManifest,
38
+ serialiseManifest,
39
+ } from '../mirror.mjs';
40
+
41
+ const __dirname = dirname(fileURLToPath(import.meta.url));
42
+ // From operator/extensions/tools/confluence/src/commands/ → five levels up.
43
+ const DEFAULT_REPO_ROOT = resolve(__dirname, '..', '..', '..', '..', '..', '..');
44
+
45
+ const HELP = `confluence mirror-folder - Mirror a Confluence folder to local markdown
46
+
47
+ Usage:
48
+ confluence mirror-folder <FOLDER_ID> [options]
49
+
50
+ Arguments:
51
+ FOLDER_ID Numeric Confluence folder ID.
52
+
53
+ Options:
54
+ --target DIR Target directory (default: operator/knowledge/mirror/).
55
+ --dry-run Walk the tree and print the plan; write nothing.
56
+ --no-reindex Skip the npm run reindex step after a successful mirror.
57
+ --help, -h Show this help.
58
+
59
+ Behaviour:
60
+ Walks the folder's descendants (folders + pages, recursively), fetches each
61
+ page as markdown, and mirrors the hierarchy into the target directory.
62
+ README.md in the target is preserved across runs; everything else is
63
+ wiped and replaced.
64
+
65
+ Examples:
66
+ confluence mirror-folder 3390046580
67
+ confluence mirror-folder 3390046580 --target /tmp/scratch --dry-run
68
+ `;
69
+
70
+ /**
71
+ * Run the mirror-folder command.
72
+ *
73
+ * @param {string[]} argv Remaining args after 'mirror-folder'
74
+ * @param {object} [deps] Dependency overrides for testing
75
+ * @returns {Promise<number>} Exit code
76
+ */
77
+ export async function run(argv = [], deps = {}) {
78
+ const log = deps.log || console.log;
79
+ const logProgress = deps.logProgress || ((msg) => console.error(msg));
80
+ const logError = deps.logError || console.error;
81
+ const { parseArgs } = await import('../cli.mjs');
82
+ const args = parseArgs(argv);
83
+
84
+ if (args.help || args.h) {
85
+ log(HELP);
86
+ return 0;
87
+ }
88
+
89
+ const folderId = args._[0];
90
+ if (!folderId) {
91
+ logError('Usage: confluence mirror-folder <FOLDER_ID> [--target DIR] [--dry-run]');
92
+ return 1;
93
+ }
94
+
95
+ const repoRoot = deps.repoRoot || process.env.DAVE_ROOT || DEFAULT_REPO_ROOT;
96
+ const target = args.target
97
+ ? resolve(args.target)
98
+ : join(repoRoot, 'operator', 'knowledge', 'mirror');
99
+ const dryRun = !!args['dry-run'];
100
+ const skipReindex = !!args['no-reindex'];
101
+
102
+ const client = deps.client || createClient();
103
+
104
+ // Resolve credentials for URL construction - this also verifies auth
105
+ // is available before we do any heavy lifting.
106
+ let domain;
107
+ try {
108
+ domain = client.getCredentials().domain;
109
+ } catch (err) {
110
+ if (err instanceof ConfluenceError) {
111
+ logError(`Failed to load credentials: ${err.message}`);
112
+ return err.exitCode;
113
+ }
114
+ throw err;
115
+ }
116
+
117
+ try {
118
+ // ── 1. Build the tree ─────────────────────────────────────────
119
+ logProgress(`Walking Confluence folder ${folderId}…`);
120
+ const rootMeta = await fetchFolderMeta(client, folderId);
121
+ logProgress(` Folder: ${rootMeta.title} (space ${rootMeta.spaceId})`);
122
+
123
+ const rootChildren = await collectFolderChildren(client, folderId, logProgress);
124
+ const pageCount = countPages(rootChildren);
125
+ logProgress(` Discovered ${pageCount} page(s) in the subtree.`);
126
+
127
+ if (pageCount === 0) {
128
+ logError('No pages found under folder. Refusing to wipe target.');
129
+ return 2;
130
+ }
131
+
132
+ // ── 2. Plan the write ─────────────────────────────────────────
133
+ const plan = planTree(rootChildren);
134
+ if (dryRun) {
135
+ log(`Target: ${target}`);
136
+ log(`Folder: ${rootMeta.title} (${folderId})`);
137
+ log(`Plan (${plan.length} entries):`);
138
+ for (const entry of plan) {
139
+ log(` [${entry.kind}] ${entry.relPath} - ${entry.node.title}`);
140
+ }
141
+ return 0;
142
+ }
143
+
144
+ // ── 3. Stage the mirror tree ──────────────────────────────────
145
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
146
+ const stagingDir = join(target, `.mirror-tmp-${stamp}`);
147
+ logProgress(`Staging directory: ${stagingDir}`);
148
+
149
+ await mkdir(target, { recursive: true });
150
+ await mkdir(stagingDir, { recursive: true });
151
+
152
+ try {
153
+ const mirrorRunAt = new Date().toISOString();
154
+ const mirroredPageIds = [];
155
+ let writtenFiles = 0;
156
+ for (const entry of plan) {
157
+ const absPath = join(stagingDir, ...entry.relPath.split('/'));
158
+ if (entry.kind === 'dir') {
159
+ await mkdir(absPath, { recursive: true });
160
+ continue;
161
+ }
162
+ // File - fetch page body and write markdown.
163
+ logProgress(` Fetching page ${entry.node.id} - ${entry.node.title}`);
164
+ const pageBody = await fetchPageMarkdown(client, entry.node.id);
165
+ const md = composeMarkdown(
166
+ {
167
+ title: entry.node.title,
168
+ pageId: entry.node.id,
169
+ url: pageUrl(domain, entry.node.id, pageBody.webuiPath),
170
+ space: rootMeta.spaceId ? String(rootMeta.spaceId) : undefined,
171
+ version: pageBody.version,
172
+ updatedAt: pageBody.updatedAt,
173
+ breadcrumb: entry.breadcrumb,
174
+ },
175
+ pageBody.markdown,
176
+ );
177
+ await mkdir(dirname(absPath), { recursive: true });
178
+ await writeFile(absPath, md, 'utf-8');
179
+ mirroredPageIds.push(String(entry.node.id));
180
+ writtenFiles++;
181
+ }
182
+ logProgress(` Wrote ${writtenFiles} file(s) to staging.`);
183
+
184
+ // ── 3b. Write the run manifest into staging ────────────────
185
+ // Lives at the domain root as a sibling of README.md. Written
186
+ // BEFORE the swap so that if any preceding page write fails
187
+ // we never publish a half-truthful manifest. The swap is
188
+ // intentionally NOT preserving manifest.json across runs -
189
+ // a stale manifest after a partial run would be misleading.
190
+ const mirrorToolVersion = await readToolVersion();
191
+ const manifest = buildManifest({
192
+ domainId: basename(target),
193
+ sourceFolderId: folderId,
194
+ sourceSpaceKey: rootMeta.spaceId ? String(rootMeta.spaceId) : undefined,
195
+ mirrorRunAt,
196
+ pageIds: mirroredPageIds,
197
+ mirrorToolVersion,
198
+ });
199
+ const manifestPath = join(stagingDir, 'manifest.json');
200
+ await writeFile(manifestPath, serialiseManifest(manifest), 'utf-8');
201
+ logProgress(` Wrote manifest.json (${manifest.pageCount} page IDs).`);
202
+
203
+ // ── 4. Atomic swap ──────────────────────────────────────────
204
+ logProgress('Swapping staged content into target…');
205
+ await swapIntoTarget(target, stagingDir);
206
+ logProgress('Swap complete.');
207
+ } finally {
208
+ // Best-effort cleanup of staging (only still exists if we failed
209
+ // mid-way or the swap logic chose to leave residue).
210
+ await rm(stagingDir, { recursive: true, force: true }).catch(() => {});
211
+ }
212
+
213
+ // ── 5. Reindex ────────────────────────────────────────────────
214
+ if (!skipReindex) {
215
+ logProgress('Running npm run reindex…');
216
+ const rc = runReindex(repoRoot, logProgress);
217
+ if (rc !== 0) {
218
+ // Soft-fail (acceptance ruling C2, 2026-07-14): on the composed v2
219
+ // workspace there is no local `reindex` script - domains.json is
220
+ // estate-composed and refreshed by the librarian, not by this tool.
221
+ // A completed mirror is a success; a stale local index is a warning.
222
+ logError(`Reindex exited with code ${rc}. Mirror completed; domains.json refresh is estate-side on v2 (warning only).`);
223
+ }
224
+ }
225
+
226
+ logProgress(`Mirror complete: ${pageCount} page(s) written to ${target}.`);
227
+ return 0;
228
+ } catch (err) {
229
+ if (err instanceof ConfluenceError) {
230
+ logError(`Mirror failed: ${err.message}`);
231
+ return err.exitCode;
232
+ }
233
+ logError(`Mirror failed: ${err.message || err}`);
234
+ return 2;
235
+ }
236
+ }
237
+
238
+ // ── API walkers ──────────────────────────────────────────────────────
239
+
240
+ /**
241
+ * Fetch folder metadata (title, spaceId).
242
+ * @param {object} client
243
+ * @param {string} folderId
244
+ */
245
+ async function fetchFolderMeta(client, folderId) {
246
+ const { data } = await client.get(`/wiki/api/v2/folders/${encodeURIComponent(folderId)}`);
247
+ return data;
248
+ }
249
+
250
+ /**
251
+ * Fetch the direct children of a folder, paginating through all pages.
252
+ * @param {object} client
253
+ * @param {string} folderId
254
+ */
255
+ async function fetchFolderDirectChildren(client, folderId) {
256
+ const results = [];
257
+ let cursor = null;
258
+ // The v2 direct-children endpoint returns _links.next as a relative path.
259
+ // Guard against unbounded loops with a generous cap.
260
+ for (let i = 0; i < 200; i++) {
261
+ const query = new URLSearchParams({ limit: '100' });
262
+ if (cursor) query.set('cursor', cursor);
263
+ const path = `/wiki/api/v2/folders/${encodeURIComponent(folderId)}/direct-children?${query.toString()}`;
264
+ const { data } = await client.get(path);
265
+ for (const item of data.results || []) results.push(item);
266
+ const nextPath = data._links?.next;
267
+ if (!nextPath) break;
268
+ const nextCursor = extractCursor(nextPath);
269
+ if (!nextCursor || nextCursor === cursor) break;
270
+ cursor = nextCursor;
271
+ }
272
+ return results;
273
+ }
274
+
275
+ /**
276
+ * Fetch a page's direct child pages. v2 exposes /pages/{id}/children which
277
+ * returns pages only (folders cannot be children of a page).
278
+ * @param {object} client
279
+ * @param {string} pageId
280
+ */
281
+ async function fetchPageChildren(client, pageId) {
282
+ const results = [];
283
+ let cursor = null;
284
+ for (let i = 0; i < 200; i++) {
285
+ const query = new URLSearchParams({ limit: '100' });
286
+ if (cursor) query.set('cursor', cursor);
287
+ const path = `/wiki/api/v2/pages/${encodeURIComponent(pageId)}/children?${query.toString()}`;
288
+ const { data } = await client.get(path);
289
+ for (const item of data.results || []) results.push(item);
290
+ const nextPath = data._links?.next;
291
+ if (!nextPath) break;
292
+ const nextCursor = extractCursor(nextPath);
293
+ if (!nextCursor || nextCursor === cursor) break;
294
+ cursor = nextCursor;
295
+ }
296
+ return results;
297
+ }
298
+
299
+ /**
300
+ * Extract the `cursor` query parameter from a v2 _links.next path.
301
+ * @param {string} nextPath
302
+ * @returns {string|null}
303
+ */
304
+ function extractCursor(nextPath) {
305
+ try {
306
+ const match = nextPath.match(/[?&]cursor=([^&]+)/);
307
+ if (match) return decodeURIComponent(match[1]);
308
+ } catch {
309
+ // fall through
310
+ }
311
+ return null;
312
+ }
313
+
314
+ /**
315
+ * Recursively collect the full subtree under a folder as a list of
316
+ * tree nodes suitable for planTree().
317
+ * @param {object} client
318
+ * @param {string} folderId
319
+ * @param {Function} logProgress
320
+ */
321
+ async function collectFolderChildren(client, folderId, logProgress) {
322
+ const raw = await fetchFolderDirectChildren(client, folderId);
323
+ const nodes = [];
324
+ for (const child of raw) {
325
+ if (child.type === 'folder') {
326
+ logProgress(` ↳ folder: ${child.title}`);
327
+ const subChildren = await collectFolderChildren(client, child.id, logProgress);
328
+ nodes.push({
329
+ type: 'folder',
330
+ id: String(child.id),
331
+ title: child.title || 'untitled',
332
+ children: subChildren,
333
+ });
334
+ } else if (child.type === 'page') {
335
+ const subChildren = await collectPageDescendants(client, child.id, logProgress);
336
+ nodes.push({
337
+ type: 'page',
338
+ id: String(child.id),
339
+ title: child.title || 'untitled',
340
+ children: subChildren,
341
+ });
342
+ }
343
+ // Other types (attachment, whiteboard, etc) are skipped deliberately.
344
+ }
345
+ return nodes;
346
+ }
347
+
348
+ /**
349
+ * Recursively collect a page's descendants (child pages and their children).
350
+ */
351
+ async function collectPageDescendants(client, pageId, logProgress) {
352
+ const children = await fetchPageChildren(client, pageId);
353
+ const nodes = [];
354
+ for (const child of children) {
355
+ const sub = await collectPageDescendants(client, child.id, logProgress);
356
+ nodes.push({
357
+ type: 'page',
358
+ id: String(child.id),
359
+ title: child.title || 'untitled',
360
+ children: sub,
361
+ });
362
+ }
363
+ return nodes;
364
+ }
365
+
366
+ /**
367
+ * Count the page nodes in a tree (ignores folders).
368
+ */
369
+ function countPages(nodes) {
370
+ let n = 0;
371
+ for (const node of nodes) {
372
+ if (node.type === 'page') n++;
373
+ if (Array.isArray(node.children)) n += countPages(node.children);
374
+ }
375
+ return n;
376
+ }
377
+
378
+ // ── Page fetching ────────────────────────────────────────────────────
379
+
380
+ /**
381
+ * Fetch a page's storage body and convert to markdown.
382
+ * Also returns version and updated-at metadata.
383
+ * @param {object} client
384
+ * @param {string} pageId
385
+ */
386
+ async function fetchPageMarkdown(client, pageId) {
387
+ const { data } = await client.get(
388
+ `/wiki/api/v2/pages/${encodeURIComponent(pageId)}?body-format=storage`,
389
+ );
390
+ const storageBody = data.body?.storage?.value || '';
391
+ const markdown = storageToMarkdown(storageBody);
392
+ return {
393
+ markdown,
394
+ version: data.version?.number,
395
+ updatedAt: data.version?.createdAt,
396
+ webuiPath: data._links?.webui,
397
+ };
398
+ }
399
+
400
+ // ── Atomic swap ──────────────────────────────────────────────────────
401
+
402
+ /**
403
+ * Move staged content into the target directory, preserving README.md and
404
+ * dotfiles. Removes everything else currently in the target.
405
+ *
406
+ * @param {string} target
407
+ * @param {string} stagingDir
408
+ */
409
+ async function swapIntoTarget(target, stagingDir) {
410
+ const existing = await readdir(target);
411
+ const { toRemove } = splitForSwap(existing.filter((e) => !isStagingDir(e)));
412
+ for (const name of toRemove) {
413
+ await rm(join(target, name), { recursive: true, force: true });
414
+ }
415
+
416
+ // Move staged entries into the target.
417
+ const staged = await readdir(stagingDir);
418
+ for (const name of staged) {
419
+ const from = join(stagingDir, name);
420
+ const to = join(target, name);
421
+ // If a preserved entry (e.g. README.md) collided with staged output, leave
422
+ // the preserved one alone and drop the staged collision.
423
+ try {
424
+ await stat(to);
425
+ // Collision - skip.
426
+ continue;
427
+ } catch {
428
+ // Doesn't exist, safe to move.
429
+ }
430
+ await rename(from, to);
431
+ }
432
+ }
433
+
434
+ function isStagingDir(name) {
435
+ return name.startsWith('.mirror-tmp-');
436
+ }
437
+
438
+ // ── Tool version ─────────────────────────────────────────────────────
439
+
440
+ /**
441
+ * Read the confluence tool's package.json version. Best-effort - if the
442
+ * file is missing or unparseable we return undefined so the manifest
443
+ * just omits the field.
444
+ *
445
+ * @returns {Promise<string|undefined>}
446
+ */
447
+ async function readToolVersion() {
448
+ try {
449
+ // From src/commands/ → up two levels to the tool root.
450
+ const pkgPath = resolve(__dirname, '..', '..', 'package.json');
451
+ const raw = await readFile(pkgPath, 'utf-8');
452
+ const pkg = JSON.parse(raw);
453
+ if (typeof pkg.version === 'string' && pkg.version.length > 0) {
454
+ return pkg.version;
455
+ }
456
+ return undefined;
457
+ } catch {
458
+ return undefined;
459
+ }
460
+ }
461
+
462
+ // ── Reindex ──────────────────────────────────────────────────────────
463
+
464
+ /**
465
+ * Run `npm run reindex` from the repo root. Returns the exit code.
466
+ * Uses spawnSync so output is streamed to the parent stdio.
467
+ */
468
+ function runReindex(repoRoot, logProgress) {
469
+ const result = spawnSync('npm', ['run', 'reindex'], {
470
+ cwd: repoRoot,
471
+ stdio: ['ignore', 'pipe', 'pipe'],
472
+ encoding: 'utf-8',
473
+ });
474
+ if (result.stdout) logProgress(result.stdout.trimEnd());
475
+ if (result.stderr) logProgress(result.stderr.trimEnd());
476
+ return result.status ?? 1;
477
+ }
478
+
479
+ // ── Re-exports for testing ───────────────────────────────────────────
480
+
481
+ export const _test = {
482
+ extractCursor,
483
+ countPages,
484
+ swapIntoTarget,
485
+ isStagingDir,
486
+ readToolVersion,
487
+ };