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.
Files changed (49) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +91 -2
  3. package/bin/arcshape.mjs +93 -0
  4. package/bin/confluence-pages.mjs +569 -0
  5. package/bin/confluence.mjs +263 -0
  6. package/bin/init.mjs +160 -0
  7. package/bin/product-citations.mjs +104 -0
  8. package/bin/session.mjs +753 -0
  9. package/bin/site.mjs +59 -0
  10. package/bin/sync.mjs +352 -0
  11. package/bin/validate-links.mjs +98 -0
  12. package/bin/validate.mjs +126 -0
  13. package/package.json +62 -4
  14. package/templates/architecture/01-introduction-and-goals.md +13 -0
  15. package/templates/architecture/02-constraints.md +13 -0
  16. package/templates/architecture/03-context-and-scope.md +11 -0
  17. package/templates/architecture/04-solution-strategy.md +13 -0
  18. package/templates/architecture/05-building-block-view.md +11 -0
  19. package/templates/architecture/06-runtime-view.md +9 -0
  20. package/templates/architecture/07-deployment-view.md +11 -0
  21. package/templates/architecture/08-crosscutting-concepts.md +13 -0
  22. package/templates/architecture/09-architecture-decisions.md +9 -0
  23. package/templates/architecture/10-quality-requirements.md +11 -0
  24. package/templates/architecture/11-risks-and-technical-debt.md +11 -0
  25. package/templates/architecture/12-glossary.md +10 -0
  26. package/templates/architecture/README.md +20 -0
  27. package/templates/assistants/.agents/skills/arcshape-adr/SKILL.md +16 -0
  28. package/templates/assistants/.agents/skills/arcshape-architecture/SKILL.md +35 -0
  29. package/templates/assistants/.agents/skills/arcshape-c4/SKILL.md +13 -0
  30. package/templates/assistants/.agents/skills/arcshape-docs-sync/SKILL.md +12 -0
  31. package/templates/assistants/.claude/commands/arcshape/adr.md +5 -0
  32. package/templates/assistants/.claude/commands/arcshape/c4.md +5 -0
  33. package/templates/assistants/.claude/commands/arcshape/document.md +5 -0
  34. package/templates/assistants/.claude/commands/arcshape/reverse-engineer.md +5 -0
  35. package/templates/assistants/.claude/commands/arcshape/sync.md +5 -0
  36. package/templates/assistants/.claude/skills/arcshape-adr/SKILL.md +16 -0
  37. package/templates/assistants/.claude/skills/arcshape-architecture/SKILL.md +35 -0
  38. package/templates/assistants/.claude/skills/arcshape-c4/SKILL.md +13 -0
  39. package/templates/assistants/.claude/skills/arcshape-docs-sync/SKILL.md +12 -0
  40. package/tools/arc42-site/LICENSE +21 -0
  41. package/tools/arc42-site/NOTICE.md +3 -0
  42. package/tools/arc42-site/README.md +3 -0
  43. package/tools/arc42-site/browser-options.mjs +14 -0
  44. package/tools/arc42-site/capture-evidence.mjs +84 -0
  45. package/tools/arc42-site/generate.mjs +227 -0
  46. package/tools/arc42-site/loadDocs.mjs +196 -0
  47. package/tools/arc42-site/render.mjs +411 -0
  48. package/tools/arc42-site/shell.mjs +761 -0
  49. package/tools/arc42-site/validate-diagrams.mjs +27 -0
@@ -0,0 +1,263 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { JSDOM } from "jsdom";
5
+ import createDOMPurify from "dompurify";
6
+ import { marked } from "marked";
7
+ import { loadDocs } from "../tools/arc42-site/loadDocs.mjs";
8
+
9
+ const window = new JSDOM("").window;
10
+ const DOMPurify = createDOMPurify(window);
11
+
12
+ export async function runConfluenceSync(options) {
13
+ if (!options.baseUrl) throw new Error("--base-url is required");
14
+ if (!options.pageId) throw new Error("--page-id is required");
15
+ const emailEnv = options.emailEnv ?? "CONFLUENCE_EMAIL";
16
+ const tokenEnv = options.tokenEnv ?? "CONFLUENCE_API_TOKEN";
17
+ const email = process.env[emailEnv];
18
+ const token = process.env[tokenEnv];
19
+ if (!email || !token)
20
+ throw new Error(
21
+ `set both ${emailEnv} and ${tokenEnv} environment variables before connecting`,
22
+ );
23
+
24
+ const baseUrl = parseBaseUrl(options.baseUrl);
25
+ const docsDir = join(process.cwd(), options.docs ?? "docs/architecture");
26
+ const docs = await loadDocs(docsDir);
27
+ const html = await renderPage(docs);
28
+ const targetDigest = digest(html);
29
+ const manifestPath = join(
30
+ process.cwd(),
31
+ ".arcshape",
32
+ "confluence-sync",
33
+ `${safePageId(options.pageId)}.json`,
34
+ );
35
+ const previous = await readManifest(manifestPath);
36
+ const auth = `Basic ${Buffer.from(`${email}:${token}`).toString("base64")}`;
37
+ const endpoint = new URL(
38
+ `${baseUrl.pathname.replace(/\/$/, "")}/api/v2/pages/${encodeURIComponent(options.pageId)}`,
39
+ baseUrl.origin,
40
+ );
41
+ endpoint.searchParams.set("body-format", "storage");
42
+
43
+ const page = await getPage(endpoint, auth);
44
+ if (
45
+ !page ||
46
+ String(page.id) !== String(options.pageId) ||
47
+ !page.title ||
48
+ !Number.isInteger(page.version?.number)
49
+ )
50
+ throw new Error("Confluence returned an incomplete page response");
51
+ const currentHtml = page.body?.storage?.value ?? "";
52
+ const currentDigest = digest(currentHtml);
53
+
54
+ if (currentDigest === targetDigest) {
55
+ if (!options.dryRun)
56
+ await writeManifest(manifestPath, {
57
+ pageId: String(page.id),
58
+ digest: targetDigest,
59
+ });
60
+ printResult({
61
+ action: options.dryRun ? "dry-run-unchanged" : "unchanged",
62
+ pageId: String(page.id),
63
+ title: page.title,
64
+ baseUrl: baseUrl.origin,
65
+ sections: docs.sections.length,
66
+ });
67
+ return { exitCode: 0 };
68
+ }
69
+
70
+ if (previous && previous.digest !== currentDigest) {
71
+ printResult({
72
+ action: "conflict",
73
+ pageId: String(page.id),
74
+ title: page.title,
75
+ reason:
76
+ "the Confluence page changed since ArcShape last synchronized it; review the remote edits before syncing",
77
+ });
78
+ return { exitCode: 1 };
79
+ }
80
+ if (!previous && !isBlankHtml(currentHtml)) {
81
+ printResult({
82
+ action: "conflict",
83
+ pageId: String(page.id),
84
+ title: page.title,
85
+ reason:
86
+ "the selected page already has content and is not managed by ArcShape; select a blank page to initialize synchronization",
87
+ });
88
+ return { exitCode: 1 };
89
+ }
90
+
91
+ if (options.dryRun) {
92
+ printResult({
93
+ action: "dry-run-update",
94
+ pageId: String(page.id),
95
+ title: page.title,
96
+ baseUrl: baseUrl.origin,
97
+ sections: docs.sections.length,
98
+ contentBytes: Buffer.byteLength(html),
99
+ });
100
+ return { exitCode: 0 };
101
+ }
102
+
103
+ const updateEndpoint = new URL(
104
+ `${baseUrl.pathname.replace(/\/$/, "")}/api/v2/pages/${encodeURIComponent(options.pageId)}`,
105
+ baseUrl.origin,
106
+ );
107
+ const updateResponse = await fetch(updateEndpoint, {
108
+ method: "PUT",
109
+ headers: {
110
+ Accept: "application/json",
111
+ Authorization: auth,
112
+ "Content-Type": "application/json",
113
+ },
114
+ body: JSON.stringify({
115
+ id: String(page.id),
116
+ status: page.status ?? "current",
117
+ title: page.title,
118
+ body: { representation: "storage", value: html },
119
+ version: {
120
+ number: page.version.number + 1,
121
+ message: "Synchronized from ArcShape Markdown",
122
+ },
123
+ }),
124
+ signal: AbortSignal.timeout(30_000),
125
+ });
126
+ if (!updateResponse.ok)
127
+ throw new Error(
128
+ `Confluence page update failed: HTTP ${updateResponse.status}`,
129
+ );
130
+
131
+ const updatedPage = await getPage(endpoint, auth);
132
+ if (updatedPage.version.number !== page.version.number + 1) {
133
+ printResult({
134
+ action: "conflict",
135
+ pageId: String(page.id),
136
+ title: page.title,
137
+ reason:
138
+ "the Confluence page changed during synchronization; review the remote version before retrying",
139
+ });
140
+ return { exitCode: 1 };
141
+ }
142
+ const syncedDigest = digest(updatedPage.body?.storage?.value ?? html);
143
+ await writeManifest(manifestPath, {
144
+ pageId: String(updatedPage.id),
145
+ digest: syncedDigest,
146
+ });
147
+ printResult({
148
+ action: "updated",
149
+ pageId: String(page.id),
150
+ title: page.title,
151
+ baseUrl: baseUrl.origin,
152
+ sections: docs.sections.length,
153
+ contentBytes: Buffer.byteLength(html),
154
+ version: updatedPage.version.number,
155
+ });
156
+ return { exitCode: 0 };
157
+ }
158
+
159
+ async function getPage(endpoint, auth) {
160
+ const response = await fetch(endpoint, {
161
+ headers: { Accept: "application/json", Authorization: auth },
162
+ signal: AbortSignal.timeout(30_000),
163
+ });
164
+ if (!response.ok)
165
+ throw new Error(`Confluence page lookup failed: HTTP ${response.status}`);
166
+ return response.json();
167
+ }
168
+
169
+ async function renderPage(docs) {
170
+ const parts = [`<h1>${escapeHtml(docs.readme.title)}</h1>`];
171
+ for (const section of docs.sections) {
172
+ parts.push(`<h2>${escapeHtml(section.title)}</h2>`);
173
+ parts.push(await marked.parse(section.body));
174
+ }
175
+ const sanitized = DOMPurify.sanitize(parts.join("\n"), {
176
+ ALLOW_DATA_ATTR: false,
177
+ });
178
+ return sanitized.replace(/\s+$/, "");
179
+ }
180
+
181
+ function parseBaseUrl(value) {
182
+ let url;
183
+ try {
184
+ url = new URL(value);
185
+ } catch {
186
+ throw new Error("--base-url must be a valid HTTPS URL");
187
+ }
188
+ if (
189
+ url.protocol !== "https:" ||
190
+ url.username ||
191
+ url.password ||
192
+ url.search ||
193
+ url.hash
194
+ )
195
+ throw new Error(
196
+ "--base-url must be HTTPS and cannot contain credentials or query parameters",
197
+ );
198
+ url.pathname = url.pathname.replace(/\/$/, "");
199
+ return url;
200
+ }
201
+
202
+ async function readManifest(path) {
203
+ try {
204
+ const manifest = JSON.parse(await readFile(path, "utf8"));
205
+ if (
206
+ manifest.version !== 1 ||
207
+ typeof manifest.digest !== "string" ||
208
+ !/^sha256:[a-f\d]{64}$/.test(manifest.digest)
209
+ )
210
+ throw new Error("invalid manifest format");
211
+ return manifest;
212
+ } catch (error) {
213
+ if (error.code === "ENOENT") return null;
214
+ throw new Error(`cannot read Confluence sync manifest: ${error.message}`);
215
+ }
216
+ }
217
+
218
+ async function writeManifest(path, manifest) {
219
+ await mkdir(dirname(path), { recursive: true });
220
+ await writeFile(
221
+ path,
222
+ `${JSON.stringify({ version: 1, ...manifest }, null, 2)}\n`,
223
+ { mode: 0o600 },
224
+ );
225
+ }
226
+
227
+ function digest(value) {
228
+ return `sha256:${createHash("sha256").update(value).digest("hex")}`;
229
+ }
230
+
231
+ function safePageId(value) {
232
+ if (!/^[a-zA-Z0-9_-]+$/.test(value))
233
+ throw new Error(
234
+ "--page-id may contain only letters, numbers, hyphens, and underscores",
235
+ );
236
+ return value;
237
+ }
238
+
239
+ function isBlankHtml(value) {
240
+ return (
241
+ value
242
+ .replace(/<[^>]*>/g, "")
243
+ .replace(/&nbsp;|&#160;|\u00a0/gi, "")
244
+ .trim() === ""
245
+ );
246
+ }
247
+
248
+ function escapeHtml(value) {
249
+ return value.replace(/[&<>"']/g, (character) => {
250
+ const entities = {
251
+ "&": "&amp;",
252
+ "<": "&lt;",
253
+ ">": "&gt;",
254
+ '"': "&quot;",
255
+ "'": "&#39;",
256
+ };
257
+ return entities[character];
258
+ });
259
+ }
260
+
261
+ function printResult(result) {
262
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
263
+ }
package/bin/init.mjs ADDED
@@ -0,0 +1,160 @@
1
+ import { lstat, readdir, readFile, mkdir, writeFile } from "node:fs/promises";
2
+ import { dirname, isAbsolute, relative, resolve, sep, join } from "node:path";
3
+
4
+ export async function runInit(args, { packageRoot }) {
5
+ const options = parseOptions(args);
6
+ if (options.help) {
7
+ process.stdout.write(
8
+ `Usage: arcshape init [--target <directory>] [--dry-run]\n\n`,
9
+ );
10
+ process.stdout.write(
11
+ `Create the Markdown arc42 structure and install Codex and Claude workflows.\n`,
12
+ );
13
+ process.stdout.write(
14
+ `Files with different existing content are left untouched and reported.\n`,
15
+ );
16
+ return { exitCode: 0 };
17
+ }
18
+
19
+ const targetRoot = resolve(process.cwd(), options.target ?? ".");
20
+ const templatesRoot = join(packageRoot, "templates", "architecture");
21
+ const architectureFiles = await collectFiles(templatesRoot);
22
+ const assistantFiles = await collectFiles(
23
+ join(packageRoot, "templates", "assistants"),
24
+ );
25
+ const files = [
26
+ ...architectureFiles.map((file) => ({
27
+ ...file,
28
+ destinationPath: join("docs", "architecture", file.relativePath),
29
+ })),
30
+ ...assistantFiles.map((file) => ({
31
+ ...file,
32
+ destinationPath: file.relativePath,
33
+ })),
34
+ ];
35
+ const written = [];
36
+ const unchanged = [];
37
+ const conflicts = [];
38
+
39
+ for (const template of files) {
40
+ const destination = resolve(targetRoot, template.destinationPath);
41
+ if (!isInside(targetRoot, destination)) {
42
+ conflicts.push({
43
+ path: destination,
44
+ reason: "resolved outside the selected target directory",
45
+ });
46
+ continue;
47
+ }
48
+ if (await hasSymlinkInPath(targetRoot, destination)) {
49
+ conflicts.push({
50
+ path: relative(targetRoot, destination),
51
+ reason: "a path component is a symbolic link",
52
+ });
53
+ continue;
54
+ }
55
+
56
+ const content = await readFile(template.source, "utf8");
57
+ if (options.dryRun) {
58
+ unchanged.push({
59
+ path: relative(targetRoot, destination),
60
+ reason: "would be created",
61
+ });
62
+ continue;
63
+ }
64
+
65
+ try {
66
+ const existing = await readFile(destination, "utf8");
67
+ if (existing === content)
68
+ unchanged.push({
69
+ path: relative(targetRoot, destination),
70
+ reason: "already up to date",
71
+ });
72
+ else
73
+ conflicts.push({
74
+ path: relative(targetRoot, destination),
75
+ reason: "file already exists with different content",
76
+ });
77
+ } catch (error) {
78
+ if (error.code !== "ENOENT") throw error;
79
+ await mkdir(dirname(destination), { recursive: true });
80
+ try {
81
+ await writeFile(destination, content, { encoding: "utf8", flag: "wx" });
82
+ written.push(relative(targetRoot, destination));
83
+ } catch (writeError) {
84
+ if (writeError.code !== "EEXIST") throw writeError;
85
+ conflicts.push({
86
+ path: relative(targetRoot, destination),
87
+ reason: "file appeared during initialization",
88
+ });
89
+ }
90
+ }
91
+ }
92
+
93
+ process.stdout.write(
94
+ `${options.dryRun ? "Planned" : "Created"} ${written.length || (options.dryRun ? unchanged.length : 0)} file(s).\n`,
95
+ );
96
+ for (const path of written) process.stdout.write(` created ${path}\n`);
97
+ for (const item of unchanged)
98
+ process.stdout.write(` unchanged ${item.path} (${item.reason})\n`);
99
+ for (const item of conflicts)
100
+ process.stderr.write(
101
+ ` conflict ${item.path}: ${item.reason}; review or move the file, then run arcshape init again.\n`,
102
+ );
103
+ if (conflicts.length)
104
+ process.stderr.write(`No conflicting file was overwritten.\n`);
105
+ return { exitCode: conflicts.length ? 1 : 0, written, unchanged, conflicts };
106
+ }
107
+
108
+ function parseOptions(args) {
109
+ const options = { dryRun: false, help: false };
110
+ for (let index = 0; index < args.length; index += 1) {
111
+ const arg = args[index];
112
+ if (arg === "--help" || arg === "-h") options.help = true;
113
+ else if (arg === "--dry-run") options.dryRun = true;
114
+ else if (arg === "--target" || arg === "-C") {
115
+ const value = args[index + 1];
116
+ if (!value || value.startsWith("-"))
117
+ throw new Error(`${arg} requires a directory path`);
118
+ options.target = value;
119
+ index += 1;
120
+ } else throw new Error(`Unknown init option: ${arg}`);
121
+ }
122
+ return options;
123
+ }
124
+
125
+ async function collectFiles(root, parent = "") {
126
+ const entries = await readdir(join(root, parent), { withFileTypes: true });
127
+ const files = [];
128
+ for (const entry of entries.sort((left, right) =>
129
+ left.name.localeCompare(right.name),
130
+ )) {
131
+ const child = join(parent, entry.name);
132
+ if (entry.isDirectory()) files.push(...(await collectFiles(root, child)));
133
+ else if (entry.isFile())
134
+ files.push({ source: join(root, child), relativePath: child });
135
+ }
136
+ return files;
137
+ }
138
+
139
+ function isInside(root, path) {
140
+ const rel = relative(root, path);
141
+ return (
142
+ rel === "" ||
143
+ (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`))
144
+ );
145
+ }
146
+
147
+ async function hasSymlinkInPath(root, destination) {
148
+ const parts = relative(root, destination).split(sep);
149
+ let current = root;
150
+ for (const part of parts) {
151
+ current = join(current, part);
152
+ try {
153
+ if ((await lstat(current)).isSymbolicLink()) return true;
154
+ } catch (error) {
155
+ if (error.code === "ENOENT") return false;
156
+ throw error;
157
+ }
158
+ }
159
+ return false;
160
+ }
@@ -0,0 +1,104 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { createRequire } from "node:module";
3
+ import { access } from "node:fs/promises";
4
+ import { isAbsolute, relative, resolve, sep } from "node:path";
5
+
6
+ export async function verifyProductCitations({
7
+ repositoryRoot,
8
+ docsDir,
9
+ productShapeCliPath,
10
+ }) {
11
+ const modelRoot = resolve(repositoryRoot, "docs", "product", "model");
12
+ try {
13
+ await access(modelRoot);
14
+ } catch (error) {
15
+ if (error.code === "ENOENT") {
16
+ return {
17
+ status: "skipped",
18
+ reason: "No docs/product/model directory was found.",
19
+ citations: [],
20
+ diagnostics: [],
21
+ summary: { total: 0 },
22
+ };
23
+ }
24
+ throw error;
25
+ }
26
+
27
+ const cliPath = productShapeCliPath ?? findProductShapeCli(repositoryRoot);
28
+ if (!cliPath) {
29
+ return {
30
+ status: "unavailable",
31
+ reason:
32
+ "A ProductShape model is present, but @prodshape/cli is not installed in this repository. Install it or run `prodshape citations verify` directly.",
33
+ citations: [],
34
+ diagnostics: [],
35
+ summary: { total: 0 },
36
+ };
37
+ }
38
+
39
+ const relativeDocsDir = relative(repositoryRoot, docsDir);
40
+ const target =
41
+ relativeDocsDir === "" ||
42
+ (!isAbsolute(relativeDocsDir) &&
43
+ relativeDocsDir !== ".." &&
44
+ !relativeDocsDir.startsWith(`..${sep}`))
45
+ ? relativeDocsDir || "."
46
+ : docsDir;
47
+ const result = spawnSync(
48
+ process.execPath,
49
+ [
50
+ cliPath,
51
+ "citations",
52
+ "verify",
53
+ target,
54
+ "--root",
55
+ repositoryRoot,
56
+ "--format",
57
+ "json",
58
+ ],
59
+ { cwd: repositoryRoot, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 },
60
+ );
61
+
62
+ if (result.error) {
63
+ return {
64
+ status: "unavailable",
65
+ reason: `Could not run ProductShape citation verification: ${result.error.message}`,
66
+ citations: [],
67
+ diagnostics: [],
68
+ summary: { total: 0 },
69
+ };
70
+ }
71
+
72
+ let report;
73
+ try {
74
+ report = JSON.parse(result.stdout);
75
+ } catch {
76
+ return {
77
+ status: "invalid",
78
+ reason:
79
+ result.stderr?.trim() ||
80
+ "ProductShape citation verification did not return valid JSON.",
81
+ citations: [],
82
+ diagnostics: [],
83
+ summary: { total: 0 },
84
+ };
85
+ }
86
+
87
+ return {
88
+ status: result.status === 0 ? "verified" : "invalid",
89
+ reason:
90
+ result.status === 0 ? undefined : result.stderr?.trim() || undefined,
91
+ citations: report.citations ?? [],
92
+ diagnostics: report.diagnostics ?? [],
93
+ summary: report.summary ?? { total: report.citations?.length ?? 0 },
94
+ };
95
+ }
96
+
97
+ function findProductShapeCli(repositoryRoot) {
98
+ try {
99
+ const require = createRequire(resolve(repositoryRoot, "package.json"));
100
+ return require.resolve("@prodshape/cli/dist/bin.js");
101
+ } catch {
102
+ return undefined;
103
+ }
104
+ }