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.
- package/README.md +54 -3
- package/bin/cross-publish.mjs +344 -0
- package/bin/forgedock.mjs +2205 -541
- package/bin/github-app-manifest.json.license +2 -0
- package/bin/hooks/session-start.mjs +4 -5
- package/bin/registry.mjs +20 -0
- package/bin/tui.mjs +172 -96
- package/commands/adopt.md +433 -0
- package/commands/analytics.md +2 -0
- package/commands/audit-agents.md +2 -0
- package/commands/audit.md +2 -0
- package/commands/autopilot.md +77 -0
- package/commands/changelog.md +354 -0
- package/commands/cleanup.md +2 -0
- package/commands/compat-audit.md +330 -0
- package/commands/deploy-info.md +6 -5
- package/commands/design-bench.md +176 -0
- package/commands/design-render-critique-loop.md +103 -0
- package/commands/design.md +259 -0
- package/commands/diagnose.md +483 -0
- package/commands/explain.md +349 -0
- package/commands/forge-stats.md +2 -0
- package/commands/forgedock-init.md +2 -0
- package/commands/geo-audit.md +2 -0
- package/commands/incident-response.md +2 -0
- package/commands/issue.md +9 -3
- package/commands/milestone.md +2 -0
- package/commands/optimize.md +411 -0
- package/commands/orchestrate.md +239 -0
- package/commands/pipeline-health.md +60 -1
- package/commands/qa-sweep.md +39 -2
- package/commands/quality-gate.md +67 -0
- package/commands/replay.md +263 -0
- package/commands/resume.md +264 -0
- package/commands/review-pr-agents.md +5 -1
- package/commands/review-pr-staging.md +160 -2
- package/commands/review-pr.md +85 -3
- package/commands/rollback.md +2 -0
- package/commands/scope.md +280 -0
- package/commands/security-audit.md +2 -0
- package/commands/status.md +262 -0
- package/commands/sync-ecosystem.md +2 -0
- package/commands/test-gate.md +929 -0
- package/commands/validate.md +2 -0
- package/commands/work-on/build/architect.md +128 -18
- package/commands/work-on/build/context.md +130 -17
- package/commands/work-on/build/implement.md +7 -3
- package/commands/work-on/build/validate.md +2 -0
- package/commands/work-on/build.md +32 -7
- package/commands/work-on/close.md +85 -0
- package/commands/work-on/decompose.md +2 -0
- package/commands/work-on/investigate.md +30 -3
- package/commands/work-on/review.md +13 -1
- package/commands/work-on-monolithic.md +2 -0
- package/commands/work-on.md +676 -38
- package/docs/BRAND.md +51 -0
- package/docs/CI.md +177 -0
- package/docs/CONFIG.md +94 -0
- package/docs/FORGE-PROTOCOL.md +324 -0
- package/docs/articles/b-vs-c-baseline.md +210 -0
- package/docs/articles/full-abc-benchmark.md +205 -0
- package/docs/articles/per-repo-adaptive-scripts-token-savings.md +174 -0
- package/docs/articles/selective-spec-loading-token-savings.md +140 -0
- package/docs/claude-breakpoints.json +47 -0
- package/docs/design/abc-benchmark.md +195 -0
- package/docs/design/design-architect-rationale.md +123 -0
- package/docs/design/design-memory.md +61 -0
- package/docs/design/design-spec-schema.md +248 -0
- package/docs/design/design-system-lint.md +150 -0
- package/docs/design/divergent-generation.md +106 -0
- package/docs/design/effects-appropriateness.md +133 -0
- package/docs/design/fixtures/briefs/README.md +47 -0
- package/docs/design/fixtures/briefs/cadence.md +39 -0
- package/docs/design/fixtures/briefs/plume.md +39 -0
- package/docs/design/fixtures/briefs/slipstream.md +39 -0
- package/docs/design/fixtures/briefs/tender.md +39 -0
- package/docs/design/fixtures/briefs/voltage.md +39 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/cadence/arm-b/run-1.html +364 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/cadence/arm-b/run-2.html +420 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/cadence/arm-b/run-3.html +506 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/plume/arm-b/run-1.html +611 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/plume/arm-b/run-2.html +594 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/plume/arm-b/run-3.html +817 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/runs.json +175 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/scorecard.json +138 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/slipstream/arm-b/run-1.html +373 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/slipstream/arm-b/run-2.html +398 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/slipstream/arm-b/run-3.html +595 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/tender/arm-b/run-1.html +531 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/tender/arm-b/run-2.html +565 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/tender/arm-b/run-3.html +674 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/voltage/arm-b/run-1.html +388 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/voltage/arm-b/run-2.html +463 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/voltage/arm-b/run-3.html +568 -0
- package/docs/design/fixtures/runs/full-abc/cadence/arm-a/run-1.html +562 -0
- package/docs/design/fixtures/runs/full-abc/cadence/arm-a/run-2.html +618 -0
- package/docs/design/fixtures/runs/full-abc/cadence/arm-a/run-3.html +618 -0
- package/docs/design/fixtures/runs/full-abc/cadence/arm-b/run-1.html +364 -0
- package/docs/design/fixtures/runs/full-abc/cadence/arm-b/run-2.html +420 -0
- package/docs/design/fixtures/runs/full-abc/cadence/arm-b/run-3.html +506 -0
- package/docs/design/fixtures/runs/full-abc/plume/arm-a/run-1.html +527 -0
- package/docs/design/fixtures/runs/full-abc/plume/arm-a/run-2.html +445 -0
- package/docs/design/fixtures/runs/full-abc/plume/arm-a/run-3.html +483 -0
- package/docs/design/fixtures/runs/full-abc/plume/arm-b/run-1.html +611 -0
- package/docs/design/fixtures/runs/full-abc/plume/arm-b/run-2.html +594 -0
- package/docs/design/fixtures/runs/full-abc/plume/arm-b/run-3.html +817 -0
- package/docs/design/fixtures/runs/full-abc/runs.json +190 -0
- package/docs/design/fixtures/runs/full-abc/scorecard.json +144 -0
- package/docs/design/fixtures/runs/full-abc/slipstream/arm-a/run-1.html +496 -0
- package/docs/design/fixtures/runs/full-abc/slipstream/arm-a/run-2.html +447 -0
- package/docs/design/fixtures/runs/full-abc/slipstream/arm-a/run-3.html +564 -0
- package/docs/design/fixtures/runs/full-abc/slipstream/arm-b/run-1.html +373 -0
- package/docs/design/fixtures/runs/full-abc/slipstream/arm-b/run-2.html +398 -0
- package/docs/design/fixtures/runs/full-abc/slipstream/arm-b/run-3.html +595 -0
- package/docs/design/fixtures/runs/full-abc/tender/arm-a/run-1.html +622 -0
- package/docs/design/fixtures/runs/full-abc/tender/arm-a/run-2.html +475 -0
- package/docs/design/fixtures/runs/full-abc/tender/arm-a/run-3.html +660 -0
- package/docs/design/fixtures/runs/full-abc/tender/arm-b/run-1.html +531 -0
- package/docs/design/fixtures/runs/full-abc/tender/arm-b/run-2.html +565 -0
- package/docs/design/fixtures/runs/full-abc/tender/arm-b/run-3.html +674 -0
- package/docs/design/fixtures/runs/full-abc/voltage/arm-a/run-1.html +525 -0
- package/docs/design/fixtures/runs/full-abc/voltage/arm-a/run-2.html +446 -0
- package/docs/design/fixtures/runs/full-abc/voltage/arm-a/run-3.html +463 -0
- package/docs/design/fixtures/runs/full-abc/voltage/arm-b/run-1.html +388 -0
- package/docs/design/fixtures/runs/full-abc/voltage/arm-b/run-2.html +463 -0
- package/docs/design/fixtures/runs/full-abc/voltage/arm-b/run-3.html +568 -0
- package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-a/run-1.html +1068 -0
- package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-a/run-2.html +1423 -0
- package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-a/run-3.html +1245 -0
- package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-b/run-1.html +305 -0
- package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-b/run-2.html +327 -0
- package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-b/run-3.html +428 -0
- package/docs/design/fixtures/runs/full-abc-opus/plume/arm-a/run-1.html +1270 -0
- package/docs/design/fixtures/runs/full-abc-opus/plume/arm-a/run-2.html +1172 -0
- package/docs/design/fixtures/runs/full-abc-opus/plume/arm-a/run-3.html +1532 -0
- package/docs/design/fixtures/runs/full-abc-opus/plume/arm-b/run-1.html +315 -0
- package/docs/design/fixtures/runs/full-abc-opus/plume/arm-b/run-2.html +342 -0
- package/docs/design/fixtures/runs/full-abc-opus/plume/arm-b/run-3.html +353 -0
- package/docs/design/fixtures/runs/full-abc-opus/runs.json +190 -0
- package/docs/design/fixtures/runs/full-abc-opus/scorecard.json +144 -0
- package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-a/run-1.html +345 -0
- package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-a/run-2.html +396 -0
- package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-a/run-3.html +502 -0
- package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-b/run-1.html +408 -0
- package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-b/run-2.html +446 -0
- package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-b/run-3.html +496 -0
- package/docs/design/fixtures/runs/full-abc-opus/tender/arm-a/run-1.html +813 -0
- package/docs/design/fixtures/runs/full-abc-opus/tender/arm-a/run-2.html +711 -0
- package/docs/design/fixtures/runs/full-abc-opus/tender/arm-a/run-3.html +721 -0
- package/docs/design/fixtures/runs/full-abc-opus/tender/arm-b/run-1.html +280 -0
- package/docs/design/fixtures/runs/full-abc-opus/tender/arm-b/run-2.html +291 -0
- package/docs/design/fixtures/runs/full-abc-opus/tender/arm-b/run-3.html +366 -0
- package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-a/run-1.html +1010 -0
- package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-a/run-2.html +836 -0
- package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-a/run-3.html +930 -0
- package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-b/run-1.html +407 -0
- package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-b/run-2.html +396 -0
- package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-b/run-3.html +529 -0
- package/docs/design/foundations/brutalist.css +294 -0
- package/docs/design/foundations/editorial.css +300 -0
- package/docs/design/foundations/minimal-luxury.css +295 -0
- package/docs/design/foundations/technical.css +293 -0
- package/docs/design/foundations/warm-photo.css +299 -0
- package/docs/design/reference-corpus.md +1726 -0
- package/docs/design/render-critique-loop.md +57 -0
- package/docs/site/getting-started.md +21 -0
- package/docs/spec-graph-schema.md +397 -0
- package/package.json +3 -3
- package/scripts/bench-scorecard.mjs +239 -0
- package/scripts/build-spec-graph.mjs +509 -0
- package/scripts/classify-lane.sh +138 -0
- package/scripts/design-system-lint.mjs +993 -0
- package/scripts/graph-query.sh +486 -0
- package/scripts/transition-label.sh +169 -0
- package/scripts/validate-pr-target.sh +30 -0
- package/scripts/validate-spec-graph.sh +423 -0
- package/templates/devdocs/README.md +72 -0
- package/templates/devdocs/agent/using-forgedock.md +3 -0
- package/templates/devdocs/agent/using-github.md +3 -0
- package/templates/devdocs/index.yaml +162 -0
- package/templates/devdocs/project/architecture.md +3 -0
- package/templates/devdocs/project/conventions.md +3 -0
- package/templates/devdocs/project/custom-instructions.md +3 -0
- package/templates/devdocs/project/glossary.md +3 -0
- package/templates/devdocs/project/stack.md +3 -0
- package/templates/devdocs/templates/commit-convention.md +3 -0
- package/templates/devdocs/templates/issue.md +3 -0
- package/templates/devdocs/templates/pull-request.md +3 -0
- package/templates/devdocs/templates/review-finding.md +3 -0
- 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>
|
|
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>
|
|
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>
|
|
@@ -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/)
|
|
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
|
-
|
|
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
|
+
});
|