arcshape 0.0.0-stage → 0.1.0
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/LICENSE +201 -0
- package/README.md +91 -2
- package/bin/arcshape.mjs +93 -0
- package/bin/confluence-pages.mjs +569 -0
- package/bin/confluence.mjs +263 -0
- package/bin/init.mjs +160 -0
- package/bin/product-citations.mjs +104 -0
- package/bin/session.mjs +753 -0
- package/bin/site.mjs +59 -0
- package/bin/sync.mjs +352 -0
- package/bin/validate-links.mjs +98 -0
- package/bin/validate.mjs +126 -0
- package/package.json +62 -4
- package/templates/architecture/01-introduction-and-goals.md +13 -0
- package/templates/architecture/02-constraints.md +13 -0
- package/templates/architecture/03-context-and-scope.md +11 -0
- package/templates/architecture/04-solution-strategy.md +13 -0
- package/templates/architecture/05-building-block-view.md +11 -0
- package/templates/architecture/06-runtime-view.md +9 -0
- package/templates/architecture/07-deployment-view.md +11 -0
- package/templates/architecture/08-crosscutting-concepts.md +13 -0
- package/templates/architecture/09-architecture-decisions.md +9 -0
- package/templates/architecture/10-quality-requirements.md +11 -0
- package/templates/architecture/11-risks-and-technical-debt.md +11 -0
- package/templates/architecture/12-glossary.md +10 -0
- package/templates/architecture/README.md +20 -0
- package/templates/assistants/.agents/skills/arcshape-adr/SKILL.md +16 -0
- package/templates/assistants/.agents/skills/arcshape-architecture/SKILL.md +35 -0
- package/templates/assistants/.agents/skills/arcshape-c4/SKILL.md +13 -0
- package/templates/assistants/.agents/skills/arcshape-docs-sync/SKILL.md +12 -0
- package/templates/assistants/.claude/commands/arcshape/adr.md +5 -0
- package/templates/assistants/.claude/commands/arcshape/c4.md +5 -0
- package/templates/assistants/.claude/commands/arcshape/document.md +5 -0
- package/templates/assistants/.claude/commands/arcshape/reverse-engineer.md +5 -0
- package/templates/assistants/.claude/commands/arcshape/sync.md +5 -0
- package/templates/assistants/.claude/skills/arcshape-adr/SKILL.md +16 -0
- package/templates/assistants/.claude/skills/arcshape-architecture/SKILL.md +35 -0
- package/templates/assistants/.claude/skills/arcshape-c4/SKILL.md +13 -0
- package/templates/assistants/.claude/skills/arcshape-docs-sync/SKILL.md +12 -0
- package/tools/arc42-site/LICENSE +21 -0
- package/tools/arc42-site/NOTICE.md +3 -0
- package/tools/arc42-site/README.md +3 -0
- package/tools/arc42-site/browser-options.mjs +14 -0
- package/tools/arc42-site/capture-evidence.mjs +84 -0
- package/tools/arc42-site/generate.mjs +227 -0
- package/tools/arc42-site/loadDocs.mjs +196 -0
- package/tools/arc42-site/render.mjs +411 -0
- package/tools/arc42-site/shell.mjs +761 -0
- package/tools/arc42-site/validate-diagrams.mjs +27 -0
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import {
|
|
4
|
+
basename,
|
|
5
|
+
dirname,
|
|
6
|
+
join,
|
|
7
|
+
posix,
|
|
8
|
+
relative,
|
|
9
|
+
resolve,
|
|
10
|
+
sep,
|
|
11
|
+
} from "node:path";
|
|
12
|
+
import { lstat, readdir } from "node:fs/promises";
|
|
13
|
+
import { JSDOM } from "jsdom";
|
|
14
|
+
import createDOMPurify from "dompurify";
|
|
15
|
+
import { marked, Renderer } from "marked";
|
|
16
|
+
import { loadDocs } from "../tools/arc42-site/loadDocs.mjs";
|
|
17
|
+
|
|
18
|
+
const DOMPurify = createDOMPurify(new JSDOM("").window);
|
|
19
|
+
|
|
20
|
+
export async function syncConfluencePages(options) {
|
|
21
|
+
const spaceId = requireValue(options.spaceId, "--space-id");
|
|
22
|
+
const parentId = requireValue(options.parentId, "--parent-id");
|
|
23
|
+
const prefix = options.titlePrefix ?? basename(process.cwd());
|
|
24
|
+
const baseUrl = parseBaseUrl(requireValue(options.baseUrl, "--base-url"));
|
|
25
|
+
const emailEnv = options.emailEnv ?? "CONFLUENCE_EMAIL";
|
|
26
|
+
const tokenEnv = options.tokenEnv ?? "CONFLUENCE_API_TOKEN";
|
|
27
|
+
const email = process.env[emailEnv];
|
|
28
|
+
const token = process.env[tokenEnv];
|
|
29
|
+
if (!email || !token)
|
|
30
|
+
throw new Error(
|
|
31
|
+
`set both ${emailEnv} and ${tokenEnv} environment variables before connecting`,
|
|
32
|
+
);
|
|
33
|
+
const auth = `Basic ${Buffer.from(`${email}:${token}`).toString("base64")}`;
|
|
34
|
+
const docsDir = join(process.cwd(), options.docs ?? "docs/architecture");
|
|
35
|
+
const docs = await loadDocs(docsDir);
|
|
36
|
+
const sections = docs.sections.map((section) => ({
|
|
37
|
+
source: toRepoPath(docsDir, section.file),
|
|
38
|
+
title: `${prefix} — ${section.title}`,
|
|
39
|
+
body: section.body,
|
|
40
|
+
}));
|
|
41
|
+
const includedPages = await loadIncludedPages(options.include ?? []);
|
|
42
|
+
const sectionSources = new Set(sections.map((section) => section.source));
|
|
43
|
+
sections.push(
|
|
44
|
+
...includedPages
|
|
45
|
+
.filter((page) => !sectionSources.has(page.source))
|
|
46
|
+
.map((page) => ({
|
|
47
|
+
...page,
|
|
48
|
+
title: `${prefix} — ${page.title}`,
|
|
49
|
+
})),
|
|
50
|
+
);
|
|
51
|
+
const readme = {
|
|
52
|
+
source: toRepoPath(docsDir, docs.readme.file),
|
|
53
|
+
title: `${prefix} — ${docs.readme.title}`,
|
|
54
|
+
body: docs.readme.body,
|
|
55
|
+
index: true,
|
|
56
|
+
};
|
|
57
|
+
const allPages = [...sections, readme];
|
|
58
|
+
const pageBySource = new Map(allPages.map((page) => [page.source, page]));
|
|
59
|
+
const manifestPath = join(
|
|
60
|
+
process.cwd(),
|
|
61
|
+
".arcshape",
|
|
62
|
+
"confluence-sync",
|
|
63
|
+
`${safeId(spaceId)}-${safeId(parentId)}.json`,
|
|
64
|
+
);
|
|
65
|
+
const manifest = await readPagesManifest(manifestPath, spaceId, parentId);
|
|
66
|
+
const outcomes = [];
|
|
67
|
+
|
|
68
|
+
// Verify destination access even when the source pages are new.
|
|
69
|
+
const parent = await getPage(baseUrl, parentId, auth);
|
|
70
|
+
if (String(parent.spaceId) !== String(spaceId))
|
|
71
|
+
throw new Error("--parent-id is not in the selected --space-id");
|
|
72
|
+
|
|
73
|
+
if (options.dryRun) {
|
|
74
|
+
for (const page of [...sections, readme]) {
|
|
75
|
+
const existing = manifest.pages[page.source];
|
|
76
|
+
if (!existing) {
|
|
77
|
+
outcomes.push({
|
|
78
|
+
source: page.source,
|
|
79
|
+
action: "create",
|
|
80
|
+
title: page.title,
|
|
81
|
+
previewBytes: Buffer.byteLength(
|
|
82
|
+
await renderPage(page, pageBySource, manifest.pages, baseUrl),
|
|
83
|
+
),
|
|
84
|
+
});
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const remote = await getPage(baseUrl, existing.pageId, auth);
|
|
88
|
+
const remoteDigest = digest(remote.body?.storage?.value ?? "");
|
|
89
|
+
const html = await renderPage(
|
|
90
|
+
page,
|
|
91
|
+
pageBySource,
|
|
92
|
+
manifest.pages,
|
|
93
|
+
baseUrl,
|
|
94
|
+
);
|
|
95
|
+
const targetDigest = digest(html);
|
|
96
|
+
const localDigest = targetDigest;
|
|
97
|
+
outcomes.push({
|
|
98
|
+
source: page.source,
|
|
99
|
+
pageId: existing.pageId,
|
|
100
|
+
action:
|
|
101
|
+
remoteDigest === existing.digest
|
|
102
|
+
? existing.sourceDigest === localDigest
|
|
103
|
+
? "unchanged"
|
|
104
|
+
: "update"
|
|
105
|
+
: remoteDigest === targetDigest
|
|
106
|
+
? "unchanged"
|
|
107
|
+
: "conflict",
|
|
108
|
+
title: page.title,
|
|
109
|
+
previewBytes: Buffer.byteLength(html),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return printResult(outcomes, "dry-run");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Publish section pages first so the README index can link to their IDs.
|
|
116
|
+
for (const page of [...sections, readme]) {
|
|
117
|
+
const html = await renderPage(page, pageBySource, manifest.pages, baseUrl);
|
|
118
|
+
const outcome = await syncPage({
|
|
119
|
+
page,
|
|
120
|
+
html,
|
|
121
|
+
baseUrl,
|
|
122
|
+
auth,
|
|
123
|
+
spaceId,
|
|
124
|
+
parentId,
|
|
125
|
+
manifest,
|
|
126
|
+
manifestPath,
|
|
127
|
+
});
|
|
128
|
+
outcomes.push(outcome);
|
|
129
|
+
if (outcome.action === "error" || outcome.action === "conflict") break;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const failed = outcomes.some(
|
|
133
|
+
(item) => item.action === "error" || item.action === "conflict",
|
|
134
|
+
);
|
|
135
|
+
printResult(outcomes, failed ? "partial" : "synced");
|
|
136
|
+
return { exitCode: failed ? 1 : 0, outcomes };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function syncPage({
|
|
140
|
+
page,
|
|
141
|
+
html,
|
|
142
|
+
baseUrl,
|
|
143
|
+
auth,
|
|
144
|
+
spaceId,
|
|
145
|
+
parentId,
|
|
146
|
+
manifest,
|
|
147
|
+
manifestPath,
|
|
148
|
+
}) {
|
|
149
|
+
const targetDigest = digest(html);
|
|
150
|
+
const existing = manifest.pages[page.source];
|
|
151
|
+
if (!existing) {
|
|
152
|
+
try {
|
|
153
|
+
const response = await fetch(endpoint(baseUrl, "/api/v2/pages"), {
|
|
154
|
+
method: "POST",
|
|
155
|
+
headers: {
|
|
156
|
+
Accept: "application/json",
|
|
157
|
+
Authorization: auth,
|
|
158
|
+
"Content-Type": "application/json",
|
|
159
|
+
},
|
|
160
|
+
body: JSON.stringify({
|
|
161
|
+
spaceId: String(spaceId),
|
|
162
|
+
parentId: String(parentId),
|
|
163
|
+
status: "current",
|
|
164
|
+
title: page.title,
|
|
165
|
+
body: { representation: "storage", value: html },
|
|
166
|
+
}),
|
|
167
|
+
signal: AbortSignal.timeout(30_000),
|
|
168
|
+
});
|
|
169
|
+
if (!response.ok)
|
|
170
|
+
return {
|
|
171
|
+
source: page.source,
|
|
172
|
+
action: "error",
|
|
173
|
+
reason: `Confluence page creation failed: HTTP ${response.status}`,
|
|
174
|
+
};
|
|
175
|
+
const created = await response.json();
|
|
176
|
+
if (!created.id)
|
|
177
|
+
return {
|
|
178
|
+
source: page.source,
|
|
179
|
+
action: "error",
|
|
180
|
+
reason: "Confluence created a page but returned no page ID",
|
|
181
|
+
};
|
|
182
|
+
const remote = await getPage(baseUrl, created.id, auth);
|
|
183
|
+
manifest.pages[page.source] = {
|
|
184
|
+
pageId: String(remote.id),
|
|
185
|
+
title: page.title,
|
|
186
|
+
digest: digest(remote.body?.storage?.value ?? html),
|
|
187
|
+
sourceDigest: targetDigest,
|
|
188
|
+
};
|
|
189
|
+
await writePagesManifest(manifestPath, manifest);
|
|
190
|
+
return {
|
|
191
|
+
source: page.source,
|
|
192
|
+
action: "created",
|
|
193
|
+
pageId: String(remote.id),
|
|
194
|
+
title: page.title,
|
|
195
|
+
};
|
|
196
|
+
} catch (error) {
|
|
197
|
+
return {
|
|
198
|
+
source: page.source,
|
|
199
|
+
action: "error",
|
|
200
|
+
reason: safeNetworkError(error),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
const remote = await getPage(baseUrl, existing.pageId, auth);
|
|
207
|
+
const currentHtml = remote.body?.storage?.value ?? "";
|
|
208
|
+
const currentDigest = digest(currentHtml);
|
|
209
|
+
if (currentDigest === targetDigest) {
|
|
210
|
+
existing.digest = currentDigest;
|
|
211
|
+
existing.sourceDigest = targetDigest;
|
|
212
|
+
await writePagesManifest(manifestPath, manifest);
|
|
213
|
+
return {
|
|
214
|
+
source: page.source,
|
|
215
|
+
action: "unchanged",
|
|
216
|
+
pageId: existing.pageId,
|
|
217
|
+
title: remote.title,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
if (
|
|
221
|
+
currentDigest === existing.digest &&
|
|
222
|
+
existing.sourceDigest === targetDigest
|
|
223
|
+
)
|
|
224
|
+
return {
|
|
225
|
+
source: page.source,
|
|
226
|
+
action: "unchanged",
|
|
227
|
+
pageId: existing.pageId,
|
|
228
|
+
title: remote.title,
|
|
229
|
+
};
|
|
230
|
+
if (currentDigest !== existing.digest)
|
|
231
|
+
return {
|
|
232
|
+
source: page.source,
|
|
233
|
+
action: "conflict",
|
|
234
|
+
pageId: existing.pageId,
|
|
235
|
+
title: remote.title,
|
|
236
|
+
reason: "Confluence page changed since the previous ArcShape sync",
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const update = await fetch(
|
|
240
|
+
endpoint(baseUrl, `/api/v2/pages/${encodeURIComponent(existing.pageId)}`),
|
|
241
|
+
{
|
|
242
|
+
method: "PUT",
|
|
243
|
+
headers: {
|
|
244
|
+
Accept: "application/json",
|
|
245
|
+
Authorization: auth,
|
|
246
|
+
"Content-Type": "application/json",
|
|
247
|
+
},
|
|
248
|
+
body: JSON.stringify({
|
|
249
|
+
id: String(remote.id),
|
|
250
|
+
status: remote.status ?? "current",
|
|
251
|
+
title: remote.title,
|
|
252
|
+
body: { representation: "storage", value: html },
|
|
253
|
+
version: {
|
|
254
|
+
number: remote.version.number + 1,
|
|
255
|
+
message: "Synchronized from ArcShape Markdown",
|
|
256
|
+
},
|
|
257
|
+
}),
|
|
258
|
+
signal: AbortSignal.timeout(30_000),
|
|
259
|
+
},
|
|
260
|
+
);
|
|
261
|
+
if (!update.ok)
|
|
262
|
+
return {
|
|
263
|
+
source: page.source,
|
|
264
|
+
action: update.status === 409 ? "conflict" : "error",
|
|
265
|
+
pageId: existing.pageId,
|
|
266
|
+
title: remote.title,
|
|
267
|
+
reason: `Confluence page update failed: HTTP ${update.status}`,
|
|
268
|
+
};
|
|
269
|
+
const updated = await getPage(baseUrl, existing.pageId, auth);
|
|
270
|
+
if (updated.version.number !== remote.version.number + 1)
|
|
271
|
+
return {
|
|
272
|
+
source: page.source,
|
|
273
|
+
action: "conflict",
|
|
274
|
+
pageId: existing.pageId,
|
|
275
|
+
title: remote.title,
|
|
276
|
+
reason: "Confluence page changed during synchronization",
|
|
277
|
+
};
|
|
278
|
+
existing.digest = digest(updated.body?.storage?.value ?? html);
|
|
279
|
+
existing.sourceDigest = targetDigest;
|
|
280
|
+
await writePagesManifest(manifestPath, manifest);
|
|
281
|
+
return {
|
|
282
|
+
source: page.source,
|
|
283
|
+
action: "updated",
|
|
284
|
+
pageId: existing.pageId,
|
|
285
|
+
title: updated.title,
|
|
286
|
+
version: updated.version.number,
|
|
287
|
+
};
|
|
288
|
+
} catch (error) {
|
|
289
|
+
return {
|
|
290
|
+
source: page.source,
|
|
291
|
+
action: "error",
|
|
292
|
+
pageId: existing.pageId,
|
|
293
|
+
reason: safeNetworkError(error),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function renderPage(page, pageBySource, pageRecords, baseUrl) {
|
|
299
|
+
const parts = [];
|
|
300
|
+
if (page.index) {
|
|
301
|
+
parts.push(await marked.parse(page.body));
|
|
302
|
+
parts.push("<h2>Architecture documents</h2><ul>");
|
|
303
|
+
for (const section of [...pageBySource.values()].filter(
|
|
304
|
+
(item) => !item.index,
|
|
305
|
+
)) {
|
|
306
|
+
const target = pageRecords[section.source];
|
|
307
|
+
const targetHref = target?.pageId
|
|
308
|
+
? pageUrl(baseUrl, target.pageId)
|
|
309
|
+
: null;
|
|
310
|
+
parts.push(
|
|
311
|
+
targetHref
|
|
312
|
+
? `<li><a href="${escapeHtml(targetHref)}">${escapeHtml(section.title)}</a></li>`
|
|
313
|
+
: `<li>${escapeHtml(section.title)} (created during sync)</li>`,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
parts.push("</ul>");
|
|
317
|
+
} else {
|
|
318
|
+
const renderer = new Renderer();
|
|
319
|
+
const renderLink = renderer.link.bind(renderer);
|
|
320
|
+
renderer.link = (token) => {
|
|
321
|
+
const localPage = resolvePageLink(page.source, token.href, pageBySource);
|
|
322
|
+
if (!localPage) return renderLink(token);
|
|
323
|
+
const target = pageRecords[localPage.source];
|
|
324
|
+
if (!target?.pageId) return renderLink(token);
|
|
325
|
+
return `<a href="${escapeHtml(pageUrl(baseUrl, target.pageId))}"${token.title ? ` title="${escapeHtml(token.title)}"` : ""}>${token.text}</a>`;
|
|
326
|
+
};
|
|
327
|
+
parts.push(await marked.parse(page.body, { renderer }));
|
|
328
|
+
}
|
|
329
|
+
return DOMPurify.sanitize(parts.join("\n"), {
|
|
330
|
+
ALLOW_DATA_ATTR: false,
|
|
331
|
+
}).trim();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function loadIncludedPages(includes) {
|
|
335
|
+
const repository = resolve(process.cwd());
|
|
336
|
+
const files = new Set();
|
|
337
|
+
for (const include of includes) {
|
|
338
|
+
const target = resolve(repository, include);
|
|
339
|
+
if (!isInside(repository, target))
|
|
340
|
+
throw new Error(`--include must stay inside the repository: ${include}`);
|
|
341
|
+
await collectIncludedFiles(repository, target, files);
|
|
342
|
+
}
|
|
343
|
+
const pages = [];
|
|
344
|
+
for (const file of [...files].sort()) {
|
|
345
|
+
const content = await readFile(file, "utf8");
|
|
346
|
+
const source = relative(repository, file).split(sep).join("/");
|
|
347
|
+
const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/.exec(content);
|
|
348
|
+
const body = frontmatter ? content.slice(frontmatter[0].length) : content;
|
|
349
|
+
const frontmatterTitle = /^title:\s*["']?(.+?)["']?\s*$/m.exec(
|
|
350
|
+
frontmatter?.[1] ?? "",
|
|
351
|
+
)?.[1];
|
|
352
|
+
const headingTitle = /^#\s+(.+?)\s*$/m.exec(body)?.[1];
|
|
353
|
+
pages.push({
|
|
354
|
+
source,
|
|
355
|
+
title: frontmatterTitle ?? headingTitle ?? basename(file, ".md"),
|
|
356
|
+
body,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
return pages;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function collectIncludedFiles(repository, target, files) {
|
|
363
|
+
let info;
|
|
364
|
+
try {
|
|
365
|
+
info = await lstat(target);
|
|
366
|
+
} catch (error) {
|
|
367
|
+
if (error.code === "ENOENT")
|
|
368
|
+
throw new Error(
|
|
369
|
+
`--include path does not exist: ${relative(repository, target)}`,
|
|
370
|
+
);
|
|
371
|
+
throw error;
|
|
372
|
+
}
|
|
373
|
+
if (info.isSymbolicLink())
|
|
374
|
+
throw new Error(
|
|
375
|
+
`--include path cannot be a symbolic link: ${relative(repository, target)}`,
|
|
376
|
+
);
|
|
377
|
+
if (info.isFile()) {
|
|
378
|
+
if (target.toLowerCase().endsWith(".md")) files.add(target);
|
|
379
|
+
else
|
|
380
|
+
throw new Error(
|
|
381
|
+
`--include file must be Markdown: ${relative(repository, target)}`,
|
|
382
|
+
);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (!info.isDirectory())
|
|
386
|
+
throw new Error(
|
|
387
|
+
`--include path must be a file or directory: ${relative(repository, target)}`,
|
|
388
|
+
);
|
|
389
|
+
for (const entry of await readdir(target, { withFileTypes: true })) {
|
|
390
|
+
if (entry.isSymbolicLink()) continue;
|
|
391
|
+
const child = join(target, entry.name);
|
|
392
|
+
if (entry.isDirectory())
|
|
393
|
+
await collectIncludedFiles(repository, child, files);
|
|
394
|
+
else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md"))
|
|
395
|
+
files.add(child);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function toRepoPath(docsDir, file) {
|
|
400
|
+
return relative(process.cwd(), join(docsDir, file)).split(sep).join("/");
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function isInside(root, path) {
|
|
404
|
+
const rel = relative(root, path);
|
|
405
|
+
return (
|
|
406
|
+
rel === "" ||
|
|
407
|
+
(!rel.startsWith(`..${sep}`) && rel !== ".." && !rel.startsWith(sep))
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function resolvePageLink(source, href, pageBySource) {
|
|
412
|
+
if (!href || href.startsWith("#")) return null;
|
|
413
|
+
let url;
|
|
414
|
+
try {
|
|
415
|
+
url = new URL(href, "https://arcshape.invalid");
|
|
416
|
+
} catch {
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
if (
|
|
420
|
+
url.origin !== "https://arcshape.invalid" ||
|
|
421
|
+
!url.pathname.endsWith(".md")
|
|
422
|
+
)
|
|
423
|
+
return null;
|
|
424
|
+
const target = decodeURIComponent(url.pathname).replace(/^\/+/, "");
|
|
425
|
+
const resolved = posix.normalize(posix.join(posix.dirname(source), target));
|
|
426
|
+
return pageBySource.get(resolved) ?? null;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function getPage(baseUrl, pageId, auth) {
|
|
430
|
+
const url = endpoint(
|
|
431
|
+
baseUrl,
|
|
432
|
+
`/api/v2/pages/${encodeURIComponent(pageId)}?body-format=storage`,
|
|
433
|
+
);
|
|
434
|
+
const response = await fetch(url, {
|
|
435
|
+
headers: { Accept: "application/json", Authorization: auth },
|
|
436
|
+
signal: AbortSignal.timeout(30_000),
|
|
437
|
+
});
|
|
438
|
+
if (!response.ok)
|
|
439
|
+
throw new Error(`Confluence page lookup failed: HTTP ${response.status}`);
|
|
440
|
+
const page = await response.json();
|
|
441
|
+
if (!page.id || !page.title || !Number.isInteger(page.version?.number))
|
|
442
|
+
throw new Error("Confluence returned an incomplete page response");
|
|
443
|
+
return page;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function endpoint(baseUrl, path) {
|
|
447
|
+
const normalizedBase = baseUrl.pathname.replace(/\/$/, "");
|
|
448
|
+
return new URL(`${normalizedBase}${path}`, baseUrl.origin);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function pageUrl(baseUrl, pageId) {
|
|
452
|
+
const path = `${baseUrl.pathname.replace(/\/$/, "")}/pages/viewpage.action`;
|
|
453
|
+
const url = new URL(path, baseUrl.origin);
|
|
454
|
+
url.searchParams.set("pageId", pageId);
|
|
455
|
+
return url.href;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function parseBaseUrl(value) {
|
|
459
|
+
let url;
|
|
460
|
+
try {
|
|
461
|
+
url = new URL(value);
|
|
462
|
+
} catch {
|
|
463
|
+
throw new Error("--base-url must be a valid HTTPS URL");
|
|
464
|
+
}
|
|
465
|
+
if (
|
|
466
|
+
url.protocol !== "https:" ||
|
|
467
|
+
url.username ||
|
|
468
|
+
url.password ||
|
|
469
|
+
url.search ||
|
|
470
|
+
url.hash
|
|
471
|
+
)
|
|
472
|
+
throw new Error(
|
|
473
|
+
"--base-url must be HTTPS and cannot contain credentials or query parameters",
|
|
474
|
+
);
|
|
475
|
+
return url;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async function readPagesManifest(path, spaceId, parentId) {
|
|
479
|
+
try {
|
|
480
|
+
const value = JSON.parse(await readFile(path, "utf8"));
|
|
481
|
+
if (
|
|
482
|
+
value.version !== 1 ||
|
|
483
|
+
String(value.spaceId) !== String(spaceId) ||
|
|
484
|
+
String(value.parentId) !== String(parentId) ||
|
|
485
|
+
value.pages === null ||
|
|
486
|
+
typeof value.pages !== "object" ||
|
|
487
|
+
Array.isArray(value.pages) ||
|
|
488
|
+
Object.values(value.pages).some(
|
|
489
|
+
(page) =>
|
|
490
|
+
!page ||
|
|
491
|
+
typeof page.pageId !== "string" ||
|
|
492
|
+
!page.pageId ||
|
|
493
|
+
typeof page.digest !== "string" ||
|
|
494
|
+
!/^sha256:[a-f\d]{64}$/.test(page.digest) ||
|
|
495
|
+
(page.sourceDigest !== undefined &&
|
|
496
|
+
(typeof page.sourceDigest !== "string" ||
|
|
497
|
+
!/^sha256:[a-f\d]{64}$/.test(page.sourceDigest))),
|
|
498
|
+
)
|
|
499
|
+
)
|
|
500
|
+
throw new Error("manifest has an unsupported or invalid format");
|
|
501
|
+
return value;
|
|
502
|
+
} catch (error) {
|
|
503
|
+
if (error.code === "ENOENT")
|
|
504
|
+
return {
|
|
505
|
+
version: 1,
|
|
506
|
+
spaceId: String(spaceId),
|
|
507
|
+
parentId: String(parentId),
|
|
508
|
+
pages: {},
|
|
509
|
+
};
|
|
510
|
+
throw new Error(`cannot read Confluence sync manifest: ${error.message}`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function writePagesManifest(path, manifest) {
|
|
515
|
+
await mkdir(dirname(path), { recursive: true });
|
|
516
|
+
await writeFile(path, `${JSON.stringify(manifest, null, 2)}\n`, {
|
|
517
|
+
mode: 0o600,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function printResult(outcomes, action) {
|
|
522
|
+
process.stdout.write(
|
|
523
|
+
`${JSON.stringify({ provider: "confluence", action, outcomes }, null, 2)}\n`,
|
|
524
|
+
);
|
|
525
|
+
return {
|
|
526
|
+
exitCode: outcomes.some(
|
|
527
|
+
(item) => item.action === "conflict" || item.action === "error",
|
|
528
|
+
)
|
|
529
|
+
? 1
|
|
530
|
+
: 0,
|
|
531
|
+
outcomes,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function requireValue(value, flag) {
|
|
536
|
+
if (!value) throw new Error(`${flag} is required`);
|
|
537
|
+
return value;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function safeId(value) {
|
|
541
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(String(value)))
|
|
542
|
+
throw new Error(
|
|
543
|
+
"Confluence space and parent IDs may contain only letters, numbers, hyphens, and underscores",
|
|
544
|
+
);
|
|
545
|
+
return String(value);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function digest(value) {
|
|
549
|
+
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function escapeHtml(value) {
|
|
553
|
+
return value.replace(/[&<>"']/g, (character) => {
|
|
554
|
+
const entities = {
|
|
555
|
+
"&": "&",
|
|
556
|
+
"<": "<",
|
|
557
|
+
">": ">",
|
|
558
|
+
'"': """,
|
|
559
|
+
"'": "'",
|
|
560
|
+
};
|
|
561
|
+
return entities[character];
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function safeNetworkError(error) {
|
|
566
|
+
return error.name === "TimeoutError"
|
|
567
|
+
? "Confluence request timed out"
|
|
568
|
+
: error.message;
|
|
569
|
+
}
|