@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,260 @@
1
+ // format.mjs - Terminal output formatting for Confluence data.
2
+ //
3
+ // Provides table rendering, page/space/comment formatting, and
4
+ // ANSI color helpers. Used by CLI commands and workspace API handlers.
5
+ //
6
+ // Zero external dependencies.
7
+
8
+ // ── ANSI color helpers ───────────────────────────────────────────────
9
+
10
+ const IS_TTY = process.stdout.isTTY;
11
+
12
+ function ansi(code, text) {
13
+ if (!IS_TTY) return text;
14
+ return `\x1b[${code}m${text}\x1b[0m`;
15
+ }
16
+
17
+ export function bold(text) { return ansi('1', text); }
18
+ export function dim(text) { return ansi('2', text); }
19
+ export function red(text) { return ansi('31', text); }
20
+ export function green(text) { return ansi('32', text); }
21
+ export function yellow(text) { return ansi('33', text); }
22
+ export function blue(text) { return ansi('34', text); }
23
+ export function cyan(text) { return ansi('36', text); }
24
+
25
+ // ── Status color mapping ─────────────────────────────────────────────
26
+
27
+ const STATUS_COLORS = {
28
+ 'current': green,
29
+ 'archived': dim,
30
+ 'draft': yellow,
31
+ 'trashed': red,
32
+ };
33
+
34
+ function colorStatus(status) {
35
+ if (!status) return '';
36
+ const lower = status.toLowerCase();
37
+ const colorFn = STATUS_COLORS[lower] || ((s) => s);
38
+ return colorFn(status);
39
+ }
40
+
41
+ // ── Table formatting ─────────────────────────────────────────────────
42
+
43
+ /**
44
+ * Render rows as an aligned text table.
45
+ *
46
+ * @param {Array<object>} rows Array of data objects
47
+ * @param {Array<object>} columns Column definitions: { key, label, width?, align? }
48
+ * - key: property name on each row
49
+ * - label: column header text
50
+ * - width: max column width (optional, auto-calculated if omitted)
51
+ * - align: 'left' (default) or 'right'
52
+ * @returns {string} Formatted table
53
+ */
54
+ export function formatTable(rows, columns) {
55
+ if (!rows || rows.length === 0) return '';
56
+
57
+ // Calculate column widths
58
+ const colWidths = columns.map((col) => {
59
+ const headerLen = col.label.length;
60
+ const maxDataLen = rows.reduce((max, row) => {
61
+ const val = String(row[col.key] ?? '');
62
+ return Math.max(max, val.length);
63
+ }, 0);
64
+ const natural = Math.max(headerLen, maxDataLen);
65
+ return col.width ? Math.min(natural, col.width) : natural;
66
+ });
67
+
68
+ // Header
69
+ const header = columns.map((col, i) => {
70
+ return pad(col.label, colWidths[i], col.align || 'left');
71
+ }).join(' ');
72
+
73
+ // Separator
74
+ const sep = colWidths.map((w) => '-'.repeat(w)).join(' ');
75
+
76
+ // Rows
77
+ const body = rows.map((row) => {
78
+ return columns.map((col, i) => {
79
+ const val = String(row[col.key] ?? '');
80
+ const truncated = val.length > colWidths[i]
81
+ ? val.slice(0, colWidths[i] - 1) + '\u2026'
82
+ : val;
83
+ return pad(truncated, colWidths[i], col.align || 'left');
84
+ }).join(' ');
85
+ });
86
+
87
+ return [bold(header), sep, ...body].join('\n');
88
+ }
89
+
90
+ function pad(str, width, align) {
91
+ if (align === 'right') return str.padStart(width);
92
+ return str.padEnd(width);
93
+ }
94
+
95
+ // ── Page formatting ──────────────────────────────────────────────────
96
+
97
+ /**
98
+ * Format a single Confluence page for terminal display.
99
+ *
100
+ * @param {object} page Page object (from v2 API response)
101
+ * @returns {string} Formatted output
102
+ */
103
+ export function formatPage(page) {
104
+ if (!page) return '';
105
+
106
+ const lines = [];
107
+
108
+ // Title
109
+ lines.push(`${bold(page.title || '(untitled)')}`);
110
+ lines.push('');
111
+
112
+ // ID + Status
113
+ const parts = [];
114
+ if (page.id) parts.push(`ID: ${page.id}`);
115
+ if (page.status) parts.push(colorStatus(page.status));
116
+ if (parts.length > 0) lines.push(parts.join(' | '));
117
+
118
+ // Space
119
+ if (page.spaceId) lines.push(`Space ID: ${page.spaceId}`);
120
+ if (page._space) lines.push(`Space: ${page._space}`);
121
+
122
+ // Version
123
+ if (page.version) {
124
+ const ver = typeof page.version === 'object' ? page.version.number : page.version;
125
+ lines.push(`Version: ${ver}`);
126
+ if (page.version.message) {
127
+ lines.push(`Version message: ${page.version.message}`);
128
+ }
129
+ }
130
+
131
+ // Author
132
+ if (page.authorId) lines.push(`Author: ${page.authorId}`);
133
+ if (page._author) lines.push(`Author: ${page._author}`);
134
+
135
+ // Labels
136
+ if (page._labels && page._labels.length > 0) {
137
+ lines.push(`Labels: ${page._labels.join(', ')}`);
138
+ }
139
+
140
+ // Dates
141
+ if (page.createdAt) lines.push(`Created: ${formatDate(page.createdAt)}`);
142
+ if (page.version?.createdAt) lines.push(`Modified: ${formatDate(page.version.createdAt)}`);
143
+
144
+ // URL
145
+ if (page._links?.webui) {
146
+ lines.push(`URL: ${page._links.webui}`);
147
+ }
148
+
149
+ return lines.join('\n');
150
+ }
151
+
152
+ /**
153
+ * Format a list of pages as a compact table.
154
+ *
155
+ * @param {Array<object>} pages Array of Confluence pages
156
+ * @returns {string} Formatted table
157
+ */
158
+ export function formatPageList(pages) {
159
+ if (!pages || pages.length === 0) return dim('No pages found.');
160
+
161
+ const rows = pages.map((page) => ({
162
+ id: page.id || '',
163
+ title: page.title || '',
164
+ status: page.status || '',
165
+ modified: page.version?.createdAt ? formatDate(page.version.createdAt) : '',
166
+ }));
167
+
168
+ return formatTable(rows, [
169
+ { key: 'id', label: 'ID', width: 12 },
170
+ { key: 'title', label: 'Title', width: 50 },
171
+ { key: 'status', label: 'Status', width: 10 },
172
+ { key: 'modified', label: 'Modified', width: 12 },
173
+ ]);
174
+ }
175
+
176
+ // ── Space formatting ─────────────────────────────────────────────────
177
+
178
+ /**
179
+ * Format a single Confluence space for terminal display.
180
+ *
181
+ * @param {object} space Space object
182
+ * @returns {string} Formatted output
183
+ */
184
+ export function formatSpace(space) {
185
+ if (!space) return '';
186
+
187
+ const lines = [];
188
+ lines.push(`${bold(space.key || '')} ${space.name || ''}`);
189
+ if (space.type) lines.push(`Type: ${space.type}`);
190
+ if (space.status) lines.push(`Status: ${colorStatus(space.status)}`);
191
+ if (space.description?.plain?.value) {
192
+ lines.push(`\n${space.description.plain.value}`);
193
+ }
194
+
195
+ return lines.join('\n');
196
+ }
197
+
198
+ /**
199
+ * Format a list of spaces as a compact table.
200
+ *
201
+ * @param {Array<object>} spaces Array of Confluence spaces
202
+ * @returns {string} Formatted table
203
+ */
204
+ export function formatSpaceList(spaces) {
205
+ if (!spaces || spaces.length === 0) return dim('No spaces found.');
206
+
207
+ const rows = spaces.map((space) => ({
208
+ key: space.key || '',
209
+ name: space.name || '',
210
+ type: space.type || '',
211
+ status: space.status || '',
212
+ }));
213
+
214
+ return formatTable(rows, [
215
+ { key: 'key', label: 'Key', width: 12 },
216
+ { key: 'name', label: 'Name', width: 40 },
217
+ { key: 'type', label: 'Type', width: 12 },
218
+ { key: 'status', label: 'Status', width: 10 },
219
+ ]);
220
+ }
221
+
222
+ // ── Comment formatting ───────────────────────────────────────────────
223
+
224
+ /**
225
+ * Format a single Confluence comment for terminal display.
226
+ *
227
+ * @param {object} comment Comment object
228
+ * @returns {string} Formatted output
229
+ */
230
+ export function formatComment(comment) {
231
+ if (!comment) return '';
232
+
233
+ const lines = [];
234
+
235
+ // v1 comment shape
236
+ const author = comment.author?.displayName || comment.authorId || 'Unknown';
237
+ const created = comment.created || comment.createdAt || '';
238
+ const version = comment.version?.number || '';
239
+
240
+ lines.push(`${bold(author)} ${dim(formatDate(created))}${version ? dim(` (v${version})`) : ''}`);
241
+
242
+ // Body - caller should render storage to text/md before passing
243
+ if (comment._bodyText) {
244
+ lines.push(comment._bodyText);
245
+ }
246
+
247
+ return lines.join('\n');
248
+ }
249
+
250
+ // ── Date formatting ──────────────────────────────────────────────────
251
+
252
+ function formatDate(isoString) {
253
+ if (!isoString) return '';
254
+ try {
255
+ const d = new Date(isoString);
256
+ return d.toISOString().slice(0, 10);
257
+ } catch {
258
+ return isoString;
259
+ }
260
+ }
@@ -0,0 +1,353 @@
1
+ // mirror.mjs - Pure helpers for the folder mirror command.
2
+ //
3
+ // All functions here are pure (no I/O, no network) so they can be unit-tested
4
+ // in isolation. The orchestration that actually walks the Confluence API and
5
+ // writes to disk lives in commands/mirror-folder.mjs.
6
+ //
7
+ // Responsibilities:
8
+ // - Sanitise Confluence page/folder titles into filesystem-safe slugs.
9
+ // - Build a markdown page body with a metadata header block.
10
+ // - Plan a mirror tree (flatten a nested folder/page structure into a list
11
+ // of files-to-write with absolute-relative paths).
12
+ // - Compute the atomic-swap plan (what to preserve, what to discard).
13
+ //
14
+ // Zero external dependencies.
15
+
16
+ // ── Title sanitisation ───────────────────────────────────────────────
17
+
18
+ /**
19
+ * Sanitise a Confluence title into a filesystem-safe slug.
20
+ *
21
+ * Rules:
22
+ * - lowercase
23
+ * - strip diacritics
24
+ * - replace spaces and path separators with hyphens
25
+ * - drop characters outside [a-z0-9._-]
26
+ * - collapse runs of hyphens
27
+ * - trim leading/trailing hyphens, dots, and underscores
28
+ * - cap length at 120 chars (leaves room for .md and numeric disambiguators)
29
+ * - fall back to a stable placeholder if the result is empty
30
+ *
31
+ * @param {string} title
32
+ * @returns {string}
33
+ */
34
+ export function sanitiseTitle(title) {
35
+ if (typeof title !== 'string' || title.length === 0) return 'untitled';
36
+
37
+ // Normalise & strip diacritics (NFD → drop combining marks).
38
+ let s = title.normalize('NFKD').replace(/[̀-ͯ]/g, '');
39
+ s = s.toLowerCase();
40
+
41
+ // Replace path separators and whitespace with hyphens.
42
+ s = s.replace(/[\\/]+/g, '-');
43
+ s = s.replace(/\s+/g, '-');
44
+
45
+ // Drop anything that isn't alphanumeric, hyphen, dot, or underscore.
46
+ s = s.replace(/[^a-z0-9._-]/g, '');
47
+
48
+ // Collapse repeated hyphens.
49
+ s = s.replace(/-{2,}/g, '-');
50
+
51
+ // Trim leading/trailing junk.
52
+ s = s.replace(/^[-._]+|[-._]+$/g, '');
53
+
54
+ if (s.length === 0) return 'untitled';
55
+ if (s.length > 120) s = s.slice(0, 120).replace(/-+$/, '');
56
+ if (s.length === 0) return 'untitled';
57
+
58
+ return s;
59
+ }
60
+
61
+ /**
62
+ * Disambiguate a slug within a directory by appending -<pageId> if a
63
+ * collision already exists. Mutates the provided usedSet so repeated
64
+ * calls stay consistent.
65
+ *
66
+ * @param {string} baseSlug
67
+ * @param {string|number} pageId Used as the disambiguator
68
+ * @param {Set<string>} usedSet Slugs already claimed in this directory
69
+ * @returns {string} A slug not present in usedSet
70
+ */
71
+ export function disambiguate(baseSlug, pageId, usedSet) {
72
+ if (!usedSet.has(baseSlug)) {
73
+ usedSet.add(baseSlug);
74
+ return baseSlug;
75
+ }
76
+ const withId = `${baseSlug}-${pageId}`;
77
+ if (!usedSet.has(withId)) {
78
+ usedSet.add(withId);
79
+ return withId;
80
+ }
81
+ // Fallback - append a counter.
82
+ let n = 2;
83
+ while (usedSet.has(`${withId}-${n}`)) n++;
84
+ const final = `${withId}-${n}`;
85
+ usedSet.add(final);
86
+ return final;
87
+ }
88
+
89
+ // ── Metadata header ─────────────────────────────────────────────────
90
+
91
+ /**
92
+ * Build the frontmatter-like metadata header block for a mirrored page.
93
+ * Uses YAML frontmatter fenced with --- so it's compatible with standard
94
+ * markdown tools and with our retrieval index-builder.
95
+ *
96
+ * Note: per-run timestamps deliberately do NOT live here - every nightly
97
+ * mirror would otherwise rewrite every page's frontmatter even when no
98
+ * Confluence content changed. The run-level timestamp is captured once,
99
+ * centrally, in the domain-root manifest (see buildManifest).
100
+ *
101
+ * @param {object} meta
102
+ * @param {string} meta.title
103
+ * @param {string} meta.pageId
104
+ * @param {string} meta.url
105
+ * @param {string} [meta.updatedAt] ISO-8601 timestamp of Confluence's last edit
106
+ * @param {string} [meta.space]
107
+ * @param {string} [meta.version]
108
+ * @param {string[]} [meta.breadcrumb] Ordered ancestor titles (nearest last)
109
+ * @returns {string}
110
+ */
111
+ export function buildMetadataHeader(meta) {
112
+ const lines = ['---'];
113
+ lines.push(`title: ${yamlString(meta.title || '')}`);
114
+ lines.push(`confluencePageId: ${yamlString(String(meta.pageId || ''))}`);
115
+ lines.push(`confluenceUrl: ${yamlString(meta.url || '')}`);
116
+ if (meta.space) lines.push(`confluenceSpace: ${yamlString(meta.space)}`);
117
+ if (meta.version != null) lines.push(`confluenceVersion: ${yamlString(String(meta.version))}`);
118
+ if (meta.updatedAt) lines.push(`confluenceUpdatedAt: ${yamlString(meta.updatedAt)}`);
119
+ if (Array.isArray(meta.breadcrumb) && meta.breadcrumb.length > 0) {
120
+ lines.push('confluenceBreadcrumb:');
121
+ for (const crumb of meta.breadcrumb) {
122
+ lines.push(` - ${yamlString(crumb)}`);
123
+ }
124
+ }
125
+ lines.push('mirrorSource: confluence');
126
+ lines.push('readOnly: true');
127
+ lines.push('---');
128
+ lines.push('');
129
+ return lines.join('\n');
130
+ }
131
+
132
+ /**
133
+ * Quote a YAML scalar value. Always double-quote and escape backslashes and
134
+ * double quotes - simplest safe path for arbitrary Confluence titles.
135
+ * @param {string} v
136
+ * @returns {string}
137
+ */
138
+ function yamlString(v) {
139
+ const s = String(v == null ? '' : v);
140
+ return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
141
+ }
142
+
143
+ /**
144
+ * Compose the full markdown body for a mirrored page: frontmatter header
145
+ * + H1 title + a blank line + the converted markdown content.
146
+ *
147
+ * @param {object} meta Passed to buildMetadataHeader
148
+ * @param {string} markdown Already-converted markdown body (may be '')
149
+ * @returns {string}
150
+ */
151
+ export function composeMarkdown(meta, markdown) {
152
+ const header = buildMetadataHeader(meta);
153
+ const title = (meta.title || 'Untitled').trim();
154
+ const body = (markdown || '').trim();
155
+ return `${header}# ${title}\n\n${body}\n`;
156
+ }
157
+
158
+ // ── Run manifest ────────────────────────────────────────────────────
159
+
160
+ /**
161
+ * Build the run-level manifest object that describes a single mirror run.
162
+ * Written once per run as `manifest.json` at the domain root. Holds the
163
+ * data that previously lived in every page's `mirrorFetchedAt` field plus
164
+ * a sorted list of page IDs so a future drift-detection feature can
165
+ * compare runs without diffing every file.
166
+ *
167
+ * Pure: takes inputs, returns a plain object - the caller serialises and
168
+ * writes it.
169
+ *
170
+ * Page IDs are sorted ascending as strings (numeric Confluence IDs sort
171
+ * lexicographically the same as numerically when zero-padding is absent
172
+ * across same-length IDs; for safety we coerce to numeric where possible
173
+ * and fall back to lexical for non-numeric IDs).
174
+ *
175
+ * @param {object} input
176
+ * @param {string} input.domainId Logical domain id (e.g. 'engineering-standards')
177
+ * @param {string|number} input.sourceFolderId Confluence folder ID being mirrored
178
+ * @param {string} [input.sourceSpaceKey] Confluence space key (or numeric space id)
179
+ * @param {string} input.mirrorRunAt ISO-8601 timestamp of this run
180
+ * @param {Array<string|number>} input.pageIds All page IDs included in the mirror
181
+ * @param {string} [input.mirrorToolVersion] Version of the confluence tool
182
+ * @returns {object}
183
+ */
184
+ export function buildManifest(input) {
185
+ const pageIds = Array.isArray(input.pageIds)
186
+ ? input.pageIds.map((id) => String(id))
187
+ : [];
188
+ const sortedPageIds = pageIds.slice().sort((a, b) => {
189
+ const an = Number(a);
190
+ const bn = Number(b);
191
+ if (Number.isFinite(an) && Number.isFinite(bn) && an !== bn) {
192
+ return an - bn;
193
+ }
194
+ if (a < b) return -1;
195
+ if (a > b) return 1;
196
+ return 0;
197
+ });
198
+
199
+ const manifest = {
200
+ domainId: String(input.domainId || ''),
201
+ sourceFolderId: String(input.sourceFolderId || ''),
202
+ };
203
+ if (input.sourceSpaceKey != null && input.sourceSpaceKey !== '') {
204
+ manifest.sourceSpaceKey = String(input.sourceSpaceKey);
205
+ }
206
+ manifest.mirrorRunAt = String(input.mirrorRunAt || '');
207
+ if (input.mirrorToolVersion) {
208
+ manifest.mirrorToolVersion = String(input.mirrorToolVersion);
209
+ }
210
+ manifest.pageCount = sortedPageIds.length;
211
+ manifest.pageIds = sortedPageIds;
212
+ return manifest;
213
+ }
214
+
215
+ /**
216
+ * Serialise a manifest object to its on-disk JSON form.
217
+ * 2-space indent, trailing newline. camelCase keys (caller's responsibility).
218
+ *
219
+ * @param {object} manifest
220
+ * @returns {string}
221
+ */
222
+ export function serialiseManifest(manifest) {
223
+ return JSON.stringify(manifest, null, 2) + '\n';
224
+ }
225
+
226
+ // ── Tree planning ───────────────────────────────────────────────────
227
+
228
+ /**
229
+ * Flatten a nested tree of folders and pages into a flat list of write
230
+ * operations. Each operation is `{ relPath, kind, node }` where `relPath`
231
+ * is a relative filesystem path (POSIX-style, using '/') rooted at the
232
+ * mirror target directory.
233
+ *
234
+ * Tree node shape:
235
+ * {
236
+ * type: 'folder' | 'page',
237
+ * id: string,
238
+ * title: string,
239
+ * children: TreeNode[] // for folder nodes and pages that have sub-pages
240
+ * }
241
+ *
242
+ * Folders become directories; their children are nested under the directory.
243
+ * Pages become .md files. Pages that themselves have sub-pages become a
244
+ * <slug>/ directory containing index.md plus the child pages.
245
+ *
246
+ * @param {object[]} rootChildren Top-level children of the root folder
247
+ * @returns {Array<{relPath: string, kind: 'file'|'dir', node: object, breadcrumb: string[]}>}
248
+ */
249
+ export function planTree(rootChildren) {
250
+ const plan = [];
251
+ walk(rootChildren || [], '', [], plan);
252
+ return plan;
253
+ }
254
+
255
+ function walk(nodes, parentRel, parentBreadcrumb, plan) {
256
+ const usedSlugs = new Set();
257
+ for (const node of nodes) {
258
+ const slug = disambiguate(sanitiseTitle(node.title || 'untitled'), node.id, usedSlugs);
259
+ const breadcrumb = [...parentBreadcrumb, node.title || ''];
260
+
261
+ if (node.type === 'folder') {
262
+ const relPath = joinRel(parentRel, slug);
263
+ plan.push({ relPath, kind: 'dir', node, breadcrumb });
264
+ if (Array.isArray(node.children) && node.children.length > 0) {
265
+ walk(node.children, relPath, breadcrumb, plan);
266
+ }
267
+ } else if (node.type === 'page') {
268
+ const hasChildren = Array.isArray(node.children) && node.children.length > 0;
269
+ if (hasChildren) {
270
+ // Page becomes a directory; its content lives in index.md.
271
+ const dirRel = joinRel(parentRel, slug);
272
+ const indexRel = joinRel(dirRel, 'index.md');
273
+ plan.push({ relPath: dirRel, kind: 'dir', node, breadcrumb });
274
+ plan.push({ relPath: indexRel, kind: 'file', node, breadcrumb });
275
+ walk(node.children, dirRel, breadcrumb, plan);
276
+ } else {
277
+ const relPath = joinRel(parentRel, `${slug}.md`);
278
+ plan.push({ relPath, kind: 'file', node, breadcrumb });
279
+ }
280
+ }
281
+ // Other types (attachment, comment, etc) are ignored.
282
+ }
283
+ }
284
+
285
+ function joinRel(parent, child) {
286
+ if (!parent) return child;
287
+ return `${parent}/${child}`;
288
+ }
289
+
290
+ // ── Atomic-swap planning ────────────────────────────────────────────
291
+
292
+ /**
293
+ * Compute the atomic-swap plan.
294
+ *
295
+ * After a successful mirror we need to:
296
+ * 1. Preserve the README.md (it documents the domain).
297
+ * 2. Remove every other file/dir currently under the target.
298
+ * 3. Move the staged mirror contents into place.
299
+ *
300
+ * This helper just returns the list of entries to preserve. Call sites
301
+ * decide how to apply it. Keeping the policy in a named function makes
302
+ * it trivial to unit-test and to change in one place.
303
+ *
304
+ * @returns {string[]} Entry names (not paths) to preserve across a swap.
305
+ */
306
+ export function preservedEntries() {
307
+ return ['README.md'];
308
+ }
309
+
310
+ /**
311
+ * Given an existing directory listing and the preservation set, split the
312
+ * listing into { toRemove, toKeep }. Input is a list of entry names (not
313
+ * paths). Purely lexical.
314
+ *
315
+ * @param {string[]} entries
316
+ * @returns {{ toRemove: string[], toKeep: string[] }}
317
+ */
318
+ export function splitForSwap(entries) {
319
+ const preserve = new Set(preservedEntries());
320
+ const toRemove = [];
321
+ const toKeep = [];
322
+ for (const name of entries) {
323
+ // Always keep dotfiles - if a user or tool ever adds a .git or similar
324
+ // config to the directory, we don't clobber it.
325
+ if (name.startsWith('.')) {
326
+ toKeep.push(name);
327
+ continue;
328
+ }
329
+ if (preserve.has(name)) {
330
+ toKeep.push(name);
331
+ } else {
332
+ toRemove.push(name);
333
+ }
334
+ }
335
+ return { toRemove, toKeep };
336
+ }
337
+
338
+ // ── URL builder ─────────────────────────────────────────────────────
339
+
340
+ /**
341
+ * Build the canonical web URL for a Confluence page.
342
+ * @param {string} domain e.g. 'yoursite'
343
+ * @param {string|number} pageId
344
+ * @param {string} [webuiPath] Optional _links.webui from v2 payload
345
+ * @returns {string}
346
+ */
347
+ export function pageUrl(domain, pageId, webuiPath) {
348
+ const base = `https://${domain}.atlassian.net`;
349
+ if (webuiPath && typeof webuiPath === 'string') {
350
+ return `${base}/wiki${webuiPath.startsWith('/') ? '' : '/'}${webuiPath}`;
351
+ }
352
+ return `${base}/wiki/pages/viewpage.action?pageId=${encodeURIComponent(pageId)}`;
353
+ }