forgedock 1.0.16 → 1.0.18

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 (190) hide show
  1. package/README.md +54 -3
  2. package/bin/cross-publish.mjs +344 -0
  3. package/bin/forgedock.mjs +2205 -541
  4. package/bin/github-app-manifest.json.license +2 -0
  5. package/bin/hooks/session-start.mjs +4 -5
  6. package/bin/registry.mjs +20 -0
  7. package/bin/tui.mjs +172 -96
  8. package/commands/adopt.md +433 -0
  9. package/commands/analytics.md +2 -0
  10. package/commands/audit-agents.md +2 -0
  11. package/commands/audit.md +2 -0
  12. package/commands/autopilot.md +77 -0
  13. package/commands/changelog.md +354 -0
  14. package/commands/cleanup.md +2 -0
  15. package/commands/compat-audit.md +330 -0
  16. package/commands/deploy-info.md +6 -5
  17. package/commands/design-bench.md +176 -0
  18. package/commands/design-render-critique-loop.md +103 -0
  19. package/commands/design.md +259 -0
  20. package/commands/diagnose.md +483 -0
  21. package/commands/explain.md +349 -0
  22. package/commands/forge-stats.md +2 -0
  23. package/commands/forgedock-init.md +2 -0
  24. package/commands/geo-audit.md +2 -0
  25. package/commands/incident-response.md +2 -0
  26. package/commands/issue.md +9 -3
  27. package/commands/milestone.md +2 -0
  28. package/commands/optimize.md +411 -0
  29. package/commands/orchestrate.md +239 -0
  30. package/commands/pipeline-health.md +60 -1
  31. package/commands/qa-sweep.md +39 -2
  32. package/commands/quality-gate.md +67 -0
  33. package/commands/replay.md +263 -0
  34. package/commands/resume.md +264 -0
  35. package/commands/review-pr-agents.md +5 -1
  36. package/commands/review-pr-staging.md +160 -2
  37. package/commands/review-pr.md +85 -3
  38. package/commands/rollback.md +2 -0
  39. package/commands/scope.md +280 -0
  40. package/commands/security-audit.md +2 -0
  41. package/commands/status.md +262 -0
  42. package/commands/sync-ecosystem.md +2 -0
  43. package/commands/test-gate.md +929 -0
  44. package/commands/validate.md +2 -0
  45. package/commands/work-on/build/architect.md +128 -18
  46. package/commands/work-on/build/context.md +130 -17
  47. package/commands/work-on/build/implement.md +7 -3
  48. package/commands/work-on/build/validate.md +2 -0
  49. package/commands/work-on/build.md +32 -7
  50. package/commands/work-on/close.md +85 -0
  51. package/commands/work-on/decompose.md +2 -0
  52. package/commands/work-on/investigate.md +30 -3
  53. package/commands/work-on/review.md +13 -1
  54. package/commands/work-on-monolithic.md +2 -0
  55. package/commands/work-on.md +676 -38
  56. package/docs/BRAND.md +51 -0
  57. package/docs/CI.md +177 -0
  58. package/docs/CONFIG.md +94 -0
  59. package/docs/FORGE-PROTOCOL.md +324 -0
  60. package/docs/articles/b-vs-c-baseline.md +210 -0
  61. package/docs/articles/full-abc-benchmark.md +205 -0
  62. package/docs/articles/per-repo-adaptive-scripts-token-savings.md +174 -0
  63. package/docs/articles/selective-spec-loading-token-savings.md +140 -0
  64. package/docs/claude-breakpoints.json +47 -0
  65. package/docs/design/abc-benchmark.md +195 -0
  66. package/docs/design/design-architect-rationale.md +123 -0
  67. package/docs/design/design-memory.md +61 -0
  68. package/docs/design/design-spec-schema.md +248 -0
  69. package/docs/design/design-system-lint.md +150 -0
  70. package/docs/design/divergent-generation.md +106 -0
  71. package/docs/design/effects-appropriateness.md +133 -0
  72. package/docs/design/fixtures/briefs/README.md +47 -0
  73. package/docs/design/fixtures/briefs/cadence.md +39 -0
  74. package/docs/design/fixtures/briefs/plume.md +39 -0
  75. package/docs/design/fixtures/briefs/slipstream.md +39 -0
  76. package/docs/design/fixtures/briefs/tender.md +39 -0
  77. package/docs/design/fixtures/briefs/voltage.md +39 -0
  78. package/docs/design/fixtures/runs/b-vs-c-baseline/cadence/arm-b/run-1.html +364 -0
  79. package/docs/design/fixtures/runs/b-vs-c-baseline/cadence/arm-b/run-2.html +420 -0
  80. package/docs/design/fixtures/runs/b-vs-c-baseline/cadence/arm-b/run-3.html +506 -0
  81. package/docs/design/fixtures/runs/b-vs-c-baseline/plume/arm-b/run-1.html +611 -0
  82. package/docs/design/fixtures/runs/b-vs-c-baseline/plume/arm-b/run-2.html +594 -0
  83. package/docs/design/fixtures/runs/b-vs-c-baseline/plume/arm-b/run-3.html +817 -0
  84. package/docs/design/fixtures/runs/b-vs-c-baseline/runs.json +175 -0
  85. package/docs/design/fixtures/runs/b-vs-c-baseline/scorecard.json +138 -0
  86. package/docs/design/fixtures/runs/b-vs-c-baseline/slipstream/arm-b/run-1.html +373 -0
  87. package/docs/design/fixtures/runs/b-vs-c-baseline/slipstream/arm-b/run-2.html +398 -0
  88. package/docs/design/fixtures/runs/b-vs-c-baseline/slipstream/arm-b/run-3.html +595 -0
  89. package/docs/design/fixtures/runs/b-vs-c-baseline/tender/arm-b/run-1.html +531 -0
  90. package/docs/design/fixtures/runs/b-vs-c-baseline/tender/arm-b/run-2.html +565 -0
  91. package/docs/design/fixtures/runs/b-vs-c-baseline/tender/arm-b/run-3.html +674 -0
  92. package/docs/design/fixtures/runs/b-vs-c-baseline/voltage/arm-b/run-1.html +388 -0
  93. package/docs/design/fixtures/runs/b-vs-c-baseline/voltage/arm-b/run-2.html +463 -0
  94. package/docs/design/fixtures/runs/b-vs-c-baseline/voltage/arm-b/run-3.html +568 -0
  95. package/docs/design/fixtures/runs/full-abc/cadence/arm-a/run-1.html +562 -0
  96. package/docs/design/fixtures/runs/full-abc/cadence/arm-a/run-2.html +618 -0
  97. package/docs/design/fixtures/runs/full-abc/cadence/arm-a/run-3.html +618 -0
  98. package/docs/design/fixtures/runs/full-abc/cadence/arm-b/run-1.html +364 -0
  99. package/docs/design/fixtures/runs/full-abc/cadence/arm-b/run-2.html +420 -0
  100. package/docs/design/fixtures/runs/full-abc/cadence/arm-b/run-3.html +506 -0
  101. package/docs/design/fixtures/runs/full-abc/plume/arm-a/run-1.html +527 -0
  102. package/docs/design/fixtures/runs/full-abc/plume/arm-a/run-2.html +445 -0
  103. package/docs/design/fixtures/runs/full-abc/plume/arm-a/run-3.html +483 -0
  104. package/docs/design/fixtures/runs/full-abc/plume/arm-b/run-1.html +611 -0
  105. package/docs/design/fixtures/runs/full-abc/plume/arm-b/run-2.html +594 -0
  106. package/docs/design/fixtures/runs/full-abc/plume/arm-b/run-3.html +817 -0
  107. package/docs/design/fixtures/runs/full-abc/runs.json +190 -0
  108. package/docs/design/fixtures/runs/full-abc/scorecard.json +144 -0
  109. package/docs/design/fixtures/runs/full-abc/slipstream/arm-a/run-1.html +496 -0
  110. package/docs/design/fixtures/runs/full-abc/slipstream/arm-a/run-2.html +447 -0
  111. package/docs/design/fixtures/runs/full-abc/slipstream/arm-a/run-3.html +564 -0
  112. package/docs/design/fixtures/runs/full-abc/slipstream/arm-b/run-1.html +373 -0
  113. package/docs/design/fixtures/runs/full-abc/slipstream/arm-b/run-2.html +398 -0
  114. package/docs/design/fixtures/runs/full-abc/slipstream/arm-b/run-3.html +595 -0
  115. package/docs/design/fixtures/runs/full-abc/tender/arm-a/run-1.html +622 -0
  116. package/docs/design/fixtures/runs/full-abc/tender/arm-a/run-2.html +475 -0
  117. package/docs/design/fixtures/runs/full-abc/tender/arm-a/run-3.html +660 -0
  118. package/docs/design/fixtures/runs/full-abc/tender/arm-b/run-1.html +531 -0
  119. package/docs/design/fixtures/runs/full-abc/tender/arm-b/run-2.html +565 -0
  120. package/docs/design/fixtures/runs/full-abc/tender/arm-b/run-3.html +674 -0
  121. package/docs/design/fixtures/runs/full-abc/voltage/arm-a/run-1.html +525 -0
  122. package/docs/design/fixtures/runs/full-abc/voltage/arm-a/run-2.html +446 -0
  123. package/docs/design/fixtures/runs/full-abc/voltage/arm-a/run-3.html +463 -0
  124. package/docs/design/fixtures/runs/full-abc/voltage/arm-b/run-1.html +388 -0
  125. package/docs/design/fixtures/runs/full-abc/voltage/arm-b/run-2.html +463 -0
  126. package/docs/design/fixtures/runs/full-abc/voltage/arm-b/run-3.html +568 -0
  127. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-a/run-1.html +1068 -0
  128. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-a/run-2.html +1423 -0
  129. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-a/run-3.html +1245 -0
  130. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-b/run-1.html +305 -0
  131. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-b/run-2.html +327 -0
  132. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-b/run-3.html +428 -0
  133. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-a/run-1.html +1270 -0
  134. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-a/run-2.html +1172 -0
  135. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-a/run-3.html +1532 -0
  136. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-b/run-1.html +315 -0
  137. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-b/run-2.html +342 -0
  138. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-b/run-3.html +353 -0
  139. package/docs/design/fixtures/runs/full-abc-opus/runs.json +190 -0
  140. package/docs/design/fixtures/runs/full-abc-opus/scorecard.json +144 -0
  141. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-a/run-1.html +345 -0
  142. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-a/run-2.html +396 -0
  143. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-a/run-3.html +502 -0
  144. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-b/run-1.html +408 -0
  145. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-b/run-2.html +446 -0
  146. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-b/run-3.html +496 -0
  147. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-a/run-1.html +813 -0
  148. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-a/run-2.html +711 -0
  149. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-a/run-3.html +721 -0
  150. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-b/run-1.html +280 -0
  151. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-b/run-2.html +291 -0
  152. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-b/run-3.html +366 -0
  153. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-a/run-1.html +1010 -0
  154. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-a/run-2.html +836 -0
  155. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-a/run-3.html +930 -0
  156. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-b/run-1.html +407 -0
  157. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-b/run-2.html +396 -0
  158. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-b/run-3.html +529 -0
  159. package/docs/design/foundations/brutalist.css +294 -0
  160. package/docs/design/foundations/editorial.css +300 -0
  161. package/docs/design/foundations/minimal-luxury.css +295 -0
  162. package/docs/design/foundations/technical.css +293 -0
  163. package/docs/design/foundations/warm-photo.css +299 -0
  164. package/docs/design/reference-corpus.md +1726 -0
  165. package/docs/design/render-critique-loop.md +57 -0
  166. package/docs/site/getting-started.md +21 -0
  167. package/docs/spec-graph-schema.md +397 -0
  168. package/package.json +3 -3
  169. package/scripts/bench-scorecard.mjs +239 -0
  170. package/scripts/build-spec-graph.mjs +509 -0
  171. package/scripts/classify-lane.sh +138 -0
  172. package/scripts/design-system-lint.mjs +993 -0
  173. package/scripts/graph-query.sh +486 -0
  174. package/scripts/transition-label.sh +169 -0
  175. package/scripts/validate-pr-target.sh +30 -0
  176. package/scripts/validate-spec-graph.sh +423 -0
  177. package/templates/devdocs/README.md +72 -0
  178. package/templates/devdocs/agent/using-forgedock.md +3 -0
  179. package/templates/devdocs/agent/using-github.md +3 -0
  180. package/templates/devdocs/index.yaml +162 -0
  181. package/templates/devdocs/project/architecture.md +3 -0
  182. package/templates/devdocs/project/conventions.md +3 -0
  183. package/templates/devdocs/project/custom-instructions.md +3 -0
  184. package/templates/devdocs/project/glossary.md +3 -0
  185. package/templates/devdocs/project/stack.md +3 -0
  186. package/templates/devdocs/templates/commit-convention.md +3 -0
  187. package/templates/devdocs/templates/issue.md +3 -0
  188. package/templates/devdocs/templates/pull-request.md +3 -0
  189. package/templates/devdocs/templates/review-finding.md +3 -0
  190. package/templates/workflows/forgedock-review.yml +96 -0
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  <p><strong>Autonomous software development for Claude Code.</strong></p>
8
8
 
9
- <p>A sub-agent orchestration layer with a deterministic pipeline that converts developer intent into structured GitHub issues, giving each agent a tightly scoped prompt with GitHub as its secondary context layer. Builds, fixes, researches, and ships end-to-end with a strict code quality bar that raises the baseline of your entire codebase over time. Fewer tokens, less time, better software. Built for production systems and security from the ground up. Benchmarked across 20,000+ GitHub issues on production codebases.</p>
9
+ <p>ForgeDock turns every bug found, every fix shipped, and every review finding into structured context that makes the next agent smarter. It catches integration bugs that code review can't see — missing route registrations, env vars present in CI but absent in deploy, Docker permission mismatches, sibling code paths left unfixed. Every finding feeds back as a prevention rule for future builds. After thousands of issues on production codebases, the system catches bugs before they reach a testing branch.</p>
10
10
 
11
11
  <a href="https://www.npmjs.com/package/forgedock"><img src="https://img.shields.io/npm/dm/forgedock?label=npm%20downloads&style=flat-square&color=CB3837" alt="npm downloads/month" /></a>&nbsp;
12
12
  <a href="https://www.npmjs.com/package/forgedock"><img src="https://img.shields.io/npm/v/forgedock?style=flat-square&color=CB3837" alt="npm version" /></a>&nbsp;
@@ -95,7 +95,13 @@ The context phase surfaced two historical bugs (#577, #587) in the same module
95
95
 
96
96
  ## Install
97
97
 
98
- **Requirements:** [Node.js 18+](https://nodejs.org/) and [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
98
+ **Requirements:** [Node.js 18+](https://nodejs.org/), [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [GitHub CLI (`gh`)](https://cli.github.com/), [`yq`](https://github.com/mikefarah/yq) (YAML parser used by pipeline commands to read `forge.yaml`), and **[Playwright MCP](https://github.com/microsoft/playwright-mcp)** (browser automation for `/qa-sweep` and visual testing commands)
99
+
100
+ > **Playwright MCP** is a guaranteed ForgeDock dependency for browser automation commands. Register it in Claude Code after installing ForgeDock:
101
+ > ```bash
102
+ > claude mcp add playwright npx @playwright/mcp@latest
103
+ > ```
104
+ > Verify with `npx forgedock doctor` — Check 9 confirms Playwright MCP is registered.
99
105
 
100
106
  ```bash
101
107
  # Install pipeline commands
@@ -132,6 +138,33 @@ npx forgedock help # Show all available commands
132
138
 
133
139
  ---
134
140
 
141
+ ## Global Command Namespace
142
+
143
+ ForgeDock owns the **global** `~/.claude/commands/` namespace. When `npx forgedock` runs, it symlinks all pipeline commands there and writes a `.symlink-source` sentinel file so other installers can detect the owner:
144
+
145
+ ```
146
+ ~/.claude/commands/
147
+ .symlink-source ← sentinel: "ForgeDock owns this namespace"
148
+ work-on.md → /path/to/forgedock/commands/work-on.md
149
+ review-pr.md → /path/to/forgedock/commands/review-pr.md
150
+ ...
151
+ ```
152
+
153
+ **Project-specific commands** should install to the **project-local** `.claude/commands/` directory instead:
154
+
155
+ ```
156
+ your-project/
157
+ .claude/
158
+ commands/
159
+ my-custom-command.md ← project-local — Claude Code merges these in
160
+ ```
161
+
162
+ Claude Code merges global (`~/.claude/commands/`) and project-local (`.claude/commands/`) automatically. Project-local commands win on name collisions.
163
+
164
+ If another tool runs its own install script that targets `~/.claude/commands/`, it will silently repoint ForgeDock's symlinks to its own source path — breaking all ForgeDock commands globally. The `.symlink-source` sentinel is a machine-readable warning to other installers; ForgeDock also emits a per-command warning during `npx forgedock install` when it detects a collision.
165
+
166
+ ---
167
+
135
168
  ## How It Works
136
169
 
137
170
  ```
@@ -200,13 +233,31 @@ Removes all ForgeDock command symlinks from `~/.claude/commands/`. Your `forge.y
200
233
 
201
234
  ---
202
235
 
236
+ ## Star History
237
+
238
+ <a href="https://star-history.com/#RapierCraftStudios/ForgeDock&Date">
239
+ <picture>
240
+ <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=RapierCraftStudios/ForgeDock&type=Date&theme=dark" />
241
+ <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=RapierCraftStudios/ForgeDock&type=Date" />
242
+ <img alt="Star History Chart" src="https://api.star-history.com/svg?repos=RapierCraftStudios/ForgeDock&type=Date" />
243
+ </picture>
244
+ </a>
245
+
246
+ ---
247
+
203
248
  ## Contributing
204
249
 
205
250
  PRs welcome. Every change goes through a PR, tested against 3+ scenarios, using conventional commits (`fix(command):`, `feat(command):`, `refactor(command):`).
206
251
 
207
252
  ## License
208
253
 
209
- [AGPL-3.0](LICENSE) — free to use, modify, and distribute. If you modify ForgeDock and offer it as a service, you must open-source your modifications under the same license.
254
+ ForgeDock uses a **dual-licensing model**:
255
+
256
+ - **[AGPL-3.0](LICENSE)** — free to use, modify, and distribute for open-source and personal use. If you modify ForgeDock and offer it as a service (including over a network), you must open-source your modifications under AGPL-3.0.
257
+
258
+ - **[Commercial License](COMMERCIAL-LICENSE.md)** — for organizations that need to use ForgeDock in proprietary workflows or products without AGPL-3.0 copyleft obligations. [Contact RapierCraft Studios](mailto:licensing@rapiercraft.studio) to obtain a commercial license.
259
+
260
+ The open-source core remains free under AGPL-3.0. The commercial license is an exception for customers who cannot meet the copyleft requirements.
210
261
 
211
262
  ---
212
263
 
@@ -0,0 +1,344 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * cross-publish.mjs
4
+ *
5
+ * Publishes draft articles from docs/articles/ to dev.to and Hashnode.
6
+ * Reads frontmatter from each .md file. If `published: false`, calls both APIs.
7
+ * Updates frontmatter to `published: true` and writes to content/publish-log.json.
8
+ *
9
+ * Required env vars:
10
+ * DEVTO_API_KEY — dev.to API key (repository secret)
11
+ * HASHNODE_ACCESS_TOKEN — Hashnode personal access token (repository secret)
12
+ * HASHNODE_PUBLICATION_ID — Hashnode publication ID (repository secret)
13
+ *
14
+ * Usage:
15
+ * node bin/cross-publish.mjs
16
+ */
17
+
18
+ import { readdir, readFile, writeFile } from 'node:fs/promises';
19
+ import { existsSync } from 'node:fs';
20
+ import { join, dirname } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+
23
+ const __dirname = dirname(fileURLToPath(import.meta.url));
24
+ const REPO_ROOT = join(__dirname, '..');
25
+ const ARTICLES_DIR = join(REPO_ROOT, 'docs', 'articles');
26
+ const PUBLISH_LOG = join(REPO_ROOT, 'content', 'publish-log.json');
27
+
28
+ const DEVTO_API_KEY = process.env.DEVTO_API_KEY;
29
+ const HASHNODE_ACCESS_TOKEN = process.env.HASHNODE_ACCESS_TOKEN;
30
+ const HASHNODE_PUBLICATION_ID = process.env.HASHNODE_PUBLICATION_ID;
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Frontmatter parsing
34
+ // ---------------------------------------------------------------------------
35
+
36
+ /**
37
+ * Parse YAML frontmatter from a markdown file body.
38
+ * Returns { frontmatter: Record<string,string|boolean|string[]>, body: string }.
39
+ */
40
+ function parseFrontmatter(content) {
41
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
42
+ if (!match) return { frontmatter: {}, body: content };
43
+
44
+ const raw = match[1];
45
+ const body = match[2];
46
+ const frontmatter = {};
47
+
48
+ for (const line of raw.split('\n')) {
49
+ const colonIdx = line.indexOf(':');
50
+ if (colonIdx === -1) continue;
51
+ const key = line.slice(0, colonIdx).trim();
52
+ const val = line.slice(colonIdx + 1).trim();
53
+
54
+ if (val === 'true') {
55
+ frontmatter[key] = true;
56
+ } else if (val === 'false') {
57
+ frontmatter[key] = false;
58
+ } else if (val.startsWith('[') && val.endsWith(']')) {
59
+ // Simple array: ["a", "b", "c"]
60
+ frontmatter[key] = val
61
+ .slice(1, -1)
62
+ .split(',')
63
+ .map(s => s.trim().replace(/^["']|["']$/g, ''))
64
+ .filter(Boolean);
65
+ } else {
66
+ // Strip surrounding quotes if present
67
+ frontmatter[key] = val.replace(/^["']|["']$/g, '');
68
+ }
69
+ }
70
+
71
+ return { frontmatter, body };
72
+ }
73
+
74
+ /**
75
+ * Serialize frontmatter back to YAML block. Sets published: true.
76
+ */
77
+ function serializeFrontmatter(frontmatter, body) {
78
+ const lines = [];
79
+ for (const [key, val] of Object.entries(frontmatter)) {
80
+ if (typeof val === 'boolean') {
81
+ lines.push(`${key}: ${val}`);
82
+ } else if (Array.isArray(val)) {
83
+ lines.push(`${key}: [${val.map(v => `"${v}"`).join(', ')}]`);
84
+ } else {
85
+ lines.push(`${key}: ${val}`);
86
+ }
87
+ }
88
+ return `---\n${lines.join('\n')}\n---\n${body}`;
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // dev.to publisher
93
+ // ---------------------------------------------------------------------------
94
+
95
+ async function publishToDevTo(article) {
96
+ const { title, description, tags, canonical_url, body, markdown } = article;
97
+
98
+ const payload = {
99
+ article: {
100
+ title,
101
+ body_markdown: markdown,
102
+ published: true,
103
+ tags: Array.isArray(tags) ? tags : [],
104
+ canonical_url: canonical_url || undefined,
105
+ description: description || undefined,
106
+ },
107
+ };
108
+
109
+ console.log(`[dev.to] Publishing: "${title}"`);
110
+
111
+ const response = await fetch('https://dev.to/api/articles', {
112
+ method: 'POST',
113
+ headers: {
114
+ 'Content-Type': 'application/json',
115
+ 'api-key': DEVTO_API_KEY,
116
+ },
117
+ body: JSON.stringify(payload),
118
+ });
119
+
120
+ const data = await response.json();
121
+
122
+ if (!response.ok) {
123
+ const errorMsg = data?.error || JSON.stringify(data);
124
+ throw new Error(`dev.to API error ${response.status}: ${errorMsg}`);
125
+ }
126
+
127
+ if (!data.id) {
128
+ throw new Error(`dev.to API returned unexpected response: ${JSON.stringify(data)}`);
129
+ }
130
+
131
+ console.log(`[dev.to] Published successfully — ID: ${data.id}, URL: ${data.url}`);
132
+ return { id: String(data.id), url: data.url };
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Hashnode publisher
137
+ // ---------------------------------------------------------------------------
138
+
139
+ async function publishToHashnode(article) {
140
+ const { title, description, tags, canonical_url, markdown } = article;
141
+
142
+ const tagObjects = (Array.isArray(tags) ? tags : []).map(t => ({
143
+ name: t,
144
+ slug: t.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
145
+ }));
146
+
147
+ const mutation = `
148
+ mutation PublishPost($input: PublishPostInput!) {
149
+ publishPost(input: $input) {
150
+ post {
151
+ id
152
+ url
153
+ }
154
+ }
155
+ }
156
+ `;
157
+
158
+ const variables = {
159
+ input: {
160
+ title,
161
+ contentMarkdown: markdown,
162
+ publicationId: HASHNODE_PUBLICATION_ID,
163
+ tags: tagObjects,
164
+ originalArticleURL: canonical_url || undefined,
165
+ metaTags: description
166
+ ? { description }
167
+ : undefined,
168
+ },
169
+ };
170
+
171
+ console.log(`[Hashnode] Publishing: "${title}"`);
172
+
173
+ const response = await fetch('https://gql.hashnode.com/', {
174
+ method: 'POST',
175
+ headers: {
176
+ 'Content-Type': 'application/json',
177
+ Authorization: HASHNODE_ACCESS_TOKEN,
178
+ },
179
+ body: JSON.stringify({ query: mutation, variables }),
180
+ });
181
+
182
+ const data = await response.json();
183
+
184
+ if (!response.ok) {
185
+ throw new Error(`Hashnode API HTTP error ${response.status}: ${JSON.stringify(data)}`);
186
+ }
187
+
188
+ if (data.errors && data.errors.length > 0) {
189
+ const errMessages = data.errors.map(e => e.message).join('; ');
190
+ throw new Error(`Hashnode GraphQL errors: ${errMessages}`);
191
+ }
192
+
193
+ const post = data?.data?.publishPost?.post;
194
+ if (!post?.id) {
195
+ throw new Error(`Hashnode API returned unexpected response: ${JSON.stringify(data)}`);
196
+ }
197
+
198
+ console.log(`[Hashnode] Published successfully — ID: ${post.id}, URL: ${post.url}`);
199
+ return { id: post.id, url: post.url };
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Publish log
204
+ // ---------------------------------------------------------------------------
205
+
206
+ async function loadPublishLog() {
207
+ try {
208
+ const raw = await readFile(PUBLISH_LOG, 'utf8');
209
+ return JSON.parse(raw);
210
+ } catch {
211
+ return { articles: {} };
212
+ }
213
+ }
214
+
215
+ async function savePublishLog(log) {
216
+ await writeFile(PUBLISH_LOG, JSON.stringify(log, null, 2) + '\n', 'utf8');
217
+ }
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // Main
221
+ // ---------------------------------------------------------------------------
222
+
223
+ async function main() {
224
+ // Validate required secrets
225
+ const missing = [];
226
+ if (!DEVTO_API_KEY) missing.push('DEVTO_API_KEY');
227
+ if (!HASHNODE_ACCESS_TOKEN) missing.push('HASHNODE_ACCESS_TOKEN');
228
+ if (!HASHNODE_PUBLICATION_ID) missing.push('HASHNODE_PUBLICATION_ID');
229
+
230
+ if (missing.length > 0) {
231
+ console.error(`ERROR: Missing required environment variables: ${missing.join(', ')}`);
232
+ console.error('Set these as repository secrets in GitHub Actions.');
233
+ process.exit(1);
234
+ }
235
+
236
+ // Load article files
237
+ const files = (await readdir(ARTICLES_DIR)).filter(f => f.endsWith('.md'));
238
+
239
+ if (files.length === 0) {
240
+ console.log('No markdown files found in docs/articles/ — nothing to publish.');
241
+ return;
242
+ }
243
+
244
+ const log = await loadPublishLog();
245
+ let anyPublished = false;
246
+ let anyFailed = false;
247
+
248
+ for (const filename of files) {
249
+ const filepath = join(ARTICLES_DIR, filename);
250
+ const raw = await readFile(filepath, 'utf8');
251
+ const { frontmatter, body } = parseFrontmatter(raw);
252
+
253
+ if (frontmatter.published === true) {
254
+ console.log(`[skip] ${filename} — already published`);
255
+ continue;
256
+ }
257
+
258
+ const { title, description, tags, canonical_url } = frontmatter;
259
+
260
+ if (!title) {
261
+ console.warn(`[warn] ${filename} — missing title in frontmatter, skipping`);
262
+ continue;
263
+ }
264
+
265
+ // Strip HTML comments (demo gif placeholders etc.) from body for publishing.
266
+ // Loop until stable to avoid incomplete sanitization when nested fragments re-form.
267
+ let cleanBody = body;
268
+ let prev;
269
+ do {
270
+ prev = cleanBody;
271
+ cleanBody = cleanBody.replace(/<!--[\s\S]*?-->/g, '');
272
+ } while (cleanBody !== prev);
273
+ cleanBody = cleanBody.trim();
274
+
275
+ const article = { title, description, tags, canonical_url, markdown: cleanBody };
276
+ const slug = filename.replace(/\.md$/, '');
277
+ const logEntry = log.articles[slug] || {};
278
+
279
+ console.log(`\n=== Publishing: ${filename} ===`);
280
+
281
+ // dev.to
282
+ let devtoResult = null;
283
+ if (logEntry.devto?.id) {
284
+ console.log(`[dev.to] Already logged as published (ID: ${logEntry.devto.id}) — skipping`);
285
+ devtoResult = logEntry.devto;
286
+ } else {
287
+ try {
288
+ devtoResult = await publishToDevTo(article);
289
+ logEntry.devto = { ...devtoResult, publishedAt: new Date().toISOString() };
290
+ log.articles[slug] = logEntry;
291
+ await savePublishLog(log);
292
+ } catch (err) {
293
+ console.error(`[dev.to] FAILED for ${filename}: ${err.message}`);
294
+ anyFailed = true;
295
+ }
296
+ }
297
+
298
+ // Hashnode
299
+ let hashnodeResult = null;
300
+ if (logEntry.hashnode?.id) {
301
+ console.log(`[Hashnode] Already logged as published (ID: ${logEntry.hashnode.id}) — skipping`);
302
+ hashnodeResult = logEntry.hashnode;
303
+ } else {
304
+ try {
305
+ hashnodeResult = await publishToHashnode(article);
306
+ logEntry.hashnode = { ...hashnodeResult, publishedAt: new Date().toISOString() };
307
+ log.articles[slug] = logEntry;
308
+ await savePublishLog(log);
309
+ } catch (err) {
310
+ console.error(`[Hashnode] FAILED for ${filename}: ${err.message}`);
311
+ anyFailed = true;
312
+ }
313
+ }
314
+
315
+ // Only mark as published in frontmatter if both platforms succeeded
316
+ if (devtoResult && hashnodeResult) {
317
+ const updated = { ...frontmatter, published: true };
318
+ const newContent = serializeFrontmatter(updated, body);
319
+ await writeFile(filepath, newContent, 'utf8');
320
+ console.log(`[ok] ${filename} — frontmatter updated to published: true`);
321
+ anyPublished = true;
322
+ } else {
323
+ console.warn(`[warn] ${filename} — not fully published on both platforms; frontmatter NOT updated`);
324
+ }
325
+ }
326
+
327
+ await savePublishLog(log);
328
+
329
+ if (anyFailed) {
330
+ console.error('\nOne or more articles failed to publish. See errors above.');
331
+ process.exit(1);
332
+ }
333
+
334
+ if (!anyPublished) {
335
+ console.log('\nNo new articles to publish — all up to date.');
336
+ } else {
337
+ console.log('\nDone. All new articles published successfully.');
338
+ }
339
+ }
340
+
341
+ main().catch(err => {
342
+ console.error('Unexpected error:', err);
343
+ process.exit(1);
344
+ });