@natjswenson/devlog 0.6.0 → 0.9.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/SKILL.md +63 -3
- package/bin/devlog.js +454 -7
- package/config.example.json +6 -0
- package/evals/fixtures/good-post.md +1 -1
- package/evals/fixtures/irreproducible-post.md +1 -1
- package/image-style/font.ttf +0 -0
- package/image-style/icons.md +234 -0
- package/image-style/style-guide.example.md +138 -0
- package/lib/config_ops.mjs +5 -2
- package/lib/core.mjs +12 -1
- package/lib/cover_gen.mjs +137 -0
- package/lib/lint_post.mjs +29 -2
- package/lib/publish_entry.mjs +115 -3
- package/lib/render_cover.mjs +251 -0
- package/lib/scan.mjs +6 -0
- package/package.json +4 -1
- package/skill-invariants.json +35 -2
package/SKILL.md
CHANGED
|
@@ -39,7 +39,7 @@ Map the user's request onto the CLI — never hand-edit `config.json`:
|
|
|
39
39
|
| Intent | Command |
|
|
40
40
|
|---|---|
|
|
41
41
|
| Show config | `npx -y @natjswenson/devlog config --json` |
|
|
42
|
-
| Add a project | `npx -y @natjswenson/devlog add-project --yes --path <abs-path> [--key K] [--remote O/R] [--label L] [--tag-prefix P] [--path-filter F]` |
|
|
42
|
+
| Add a project | `npx -y @natjswenson/devlog add-project --yes --path <abs-path> [--key K] [--remote O/R] [--label L] [--tag-prefix P] [--path-filter F] [--private]` |
|
|
43
43
|
| Remove a project | `npx -y @natjswenson/devlog remove-project <key> --yes` |
|
|
44
44
|
| Change a setting | `npx -y @natjswenson/devlog set <field> <value>` (settable: `targetRepo`, `branch`, `gitAuthor`, `githubUser`, `voicePath`, `deepDive.minSources`, `deepDive.topicDomains`) |
|
|
45
45
|
|
|
@@ -51,6 +51,16 @@ get-url origin`. In a monorepo, suggest a `--path-filter` (the project's subdir)
|
|
|
51
51
|
and show the resulting project list. For **remove-project**, confirm once before running;
|
|
52
52
|
tell the user published entries are not deleted.
|
|
53
53
|
|
|
54
|
+
**Private repos.** If the user says the repo is private (or a source repo happens to be
|
|
55
|
+
private on GitHub even though it's configured normally), pass `--private`. A private
|
|
56
|
+
project's commits are never marked public in the scan (see Generate mode), so no post ever
|
|
57
|
+
links a commit for it, `## Changelog` is always omitted, and `--remote` is optional — the
|
|
58
|
+
tool has no reason to know or use the repo's GitHub location. This is a declared project
|
|
59
|
+
*type*, not something auto-detected from the GitHub API: `remoteMatches` + "on the
|
|
60
|
+
published branch" alone doesn't imply the repo is public, so a project with a real, correctly
|
|
61
|
+
configured remote that happens to sit in a private repo needs this flag or its commits would
|
|
62
|
+
otherwise scan as public.
|
|
63
|
+
|
|
54
64
|
If any command prints `{"error": "config-missing", ...}`, tell the user to run
|
|
55
65
|
`npx @natjswenson/devlog init` first. On `config-invalid`, show the message and offer to
|
|
56
66
|
fix the named field via `set`.
|
|
@@ -170,7 +180,9 @@ title: "<essay-style title in sentence case (capitalize only the first word and
|
|
|
170
180
|
date: <release date from scan>
|
|
171
181
|
project: <project key>
|
|
172
182
|
version: <version from scan>
|
|
173
|
-
tags: [<
|
|
183
|
+
tags: [<5-10 specific, lowercase tags — tools, techniques, and concepts actually
|
|
184
|
+
present in the post, not just broad topic labels, e.g. git, ci-cd,
|
|
185
|
+
release-engineering, github-actions, semver, changelog-automation>]
|
|
174
186
|
summary: "<1-2 sentence hook that frames the how-to, not just what shipped>"
|
|
175
187
|
---
|
|
176
188
|
|
|
@@ -282,15 +294,63 @@ critique, not a rubber stamp.
|
|
|
282
294
|
Clone once, publish each entry through the CLI, push once. `targetRepo` and `branch` come
|
|
283
295
|
from validated config — still single-quote every interpolated value.
|
|
284
296
|
|
|
297
|
+
Each release also gets a cover image, composed inline in this same loop right before that
|
|
298
|
+
release's own `publish-entry` call — a self-contained HTML/CSS (or inline SVG) document,
|
|
299
|
+
rasterized locally, never sent to any external service:
|
|
300
|
+
|
|
285
301
|
```bash
|
|
286
302
|
mktemp -d # → record the absolute path, e.g. /var/folders/.../tmp.abc
|
|
287
303
|
git -C '<abs-tmp>' clone --depth=1 'https://github.com/<targetRepo>.git'
|
|
288
304
|
|
|
289
305
|
# Per release (refuses to overwrite an existing entry — on {"error": ...,
|
|
290
306
|
# "message": "... immutable ..."} skip that release and note it):
|
|
307
|
+
|
|
308
|
+
# 1. Style guide + up to 3 reference images of recently published covers.
|
|
309
|
+
npx -y @natjswenson/devlog cover-context '<key>' '<version>' \
|
|
310
|
+
--clone '<abs-tmp>/<repo-name>'
|
|
311
|
+
# On {"error": "style-guide-missing", ...}: skip cover composition for this release
|
|
312
|
+
# entirely — proceed straight to publish-entry with no --cover flag. Never block
|
|
313
|
+
# publish on a missing style guide.
|
|
314
|
+
|
|
315
|
+
# 2. Compose the cover using ONLY this release's title/tags/summary/`## Shipped` text
|
|
316
|
+
# (never the raw draft file, never `## Changelog`) plus the returned style guide,
|
|
317
|
+
# icon catalog, and reference images. A cover that just re-renders the title in large text is a failure —
|
|
318
|
+
# find the one concrete mechanism this release is actually about
|
|
319
|
+
# (not the project name, not "a bug fix") and draw ONE custom inline-SVG illustration
|
|
320
|
+
# of it, sized as the dominant visual element of the canvas; title/kicker stay
|
|
321
|
+
# secondary. Two different releases should never produce visually similar covers.
|
|
322
|
+
#
|
|
323
|
+
# Draw the illustration inside a `#hero-zone` container at exactly
|
|
324
|
+
# `x:150 y:425 width:1300 height:400` (render-cover mechanically checks this box and
|
|
325
|
+
# refuses to render otherwise) — pick ONE of two composition slots per post: single
|
|
326
|
+
# centered hero (one freehand mechanism, nothing else), or two-node before/after (a
|
|
327
|
+
# left node, a right node, a connecting line, all freehand). Snap interior key points
|
|
328
|
+
# to a 25px coordinate grid. Catalog icons (image-style/icons.md) are never placed
|
|
329
|
+
# inside `#hero-zone` — they may only appear as an optional small accent glyph near
|
|
330
|
+
# the kicker/title area, entirely outside the hero zone, its bottom edge no lower than
|
|
331
|
+
# y:400. See the style guide's hero-zone grid contract section before composing.
|
|
332
|
+
# Write the result with the Write tool to '<abs-scratch>/<key>/<version>.html' — a
|
|
333
|
+
# full document starting with `<!DOCTYPE html>`, sized
|
|
334
|
+
# `html, body { margin:0; width:1600px; height:900px; }`, referencing the bundled
|
|
335
|
+
# font only as `font-family: 'DevlogCoverFont', sans-serif`.
|
|
336
|
+
|
|
337
|
+
# 3. Rasterize it. On failure (render timeout / Chromium not installed / font missing /
|
|
338
|
+
# a #hero-zone problem — missing, duplicate, wrong position/size, or a catalog icon
|
|
339
|
+
# overlapping it),
|
|
340
|
+
# the .html is left in place for debugging — retry composing once with the error text
|
|
341
|
+
# fed back, or give up and proceed with no --cover flag.
|
|
342
|
+
npx -y @natjswenson/devlog render-cover '<abs-scratch>/<key>/<version>.html' \
|
|
343
|
+
--project '<key>' --slug '<version>' --out '<abs-scratch>'
|
|
344
|
+
# Show the rendered <abs-scratch>/<key>/<version>.png in this session before continuing —
|
|
345
|
+
# this interactive review IS the quality gate for the cover, the same way Step 4 is for
|
|
346
|
+
# the prose.
|
|
347
|
+
|
|
291
348
|
npx -y @natjswenson/devlog publish-entry \
|
|
292
349
|
--clone '<abs-tmp>/<repo-name>' --project '<key>' \
|
|
293
|
-
--version '<version>' --entry '<abs-draft-path>'
|
|
350
|
+
--version '<version>' --entry '<abs-draft-path>' \
|
|
351
|
+
--cover '<abs-scratch>/<key>/<version>.png'
|
|
352
|
+
# Omit --cover entirely if no cover was produced for this release (missing style guide,
|
|
353
|
+
# a render failure not worth a second attempt) — publish still proceeds normally.
|
|
294
354
|
|
|
295
355
|
git -C '<abs-tmp>/<repo-name>' add .
|
|
296
356
|
git -C '<abs-tmp>/<repo-name>' commit -m 'devlog: add release entries'
|
package/bin/devlog.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn, spawnSync, execSync } from 'node:child_process';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, realpathSync,
|
|
5
|
+
readdirSync, statSync, unlinkSync, rmSync, mkdtempSync,
|
|
6
|
+
} from 'node:fs';
|
|
4
7
|
import { dirname, join, resolve, basename } from 'node:path';
|
|
5
8
|
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
6
10
|
import { createRequire } from 'node:module';
|
|
7
11
|
import { parseArgs } from 'node:util';
|
|
8
12
|
import prompts from 'prompts';
|
|
9
13
|
import kleur from 'kleur';
|
|
14
|
+
import { chromium } from 'playwright';
|
|
10
15
|
|
|
11
16
|
import {
|
|
12
17
|
SHELL_QUOTE_BREAK,
|
|
@@ -28,9 +33,11 @@ import {
|
|
|
28
33
|
resolveDeepDive,
|
|
29
34
|
} from '../lib/core.mjs';
|
|
30
35
|
import { scanAll } from '../lib/scan.mjs';
|
|
31
|
-
import { lintPost } from '../lib/lint_post.mjs';
|
|
32
|
-
import { publishEntry } from '../lib/publish_entry.mjs';
|
|
36
|
+
import { lintPost, parseFrontmatter, splitSections } from '../lib/lint_post.mjs';
|
|
37
|
+
import { publishEntry, addCoverToExistingEntry } from '../lib/publish_entry.mjs';
|
|
33
38
|
import { addProject, removeProject, setField, SETTABLE_FIELDS } from '../lib/config_ops.mjs';
|
|
39
|
+
import { loadStyleGuide, getRecentCovers, mergeManifestEntries } from '../lib/cover_gen.mjs';
|
|
40
|
+
import { renderCoverImage } from '../lib/render_cover.mjs';
|
|
34
41
|
|
|
35
42
|
// Re-export the shared validators so existing importers (tests, docs) keep a
|
|
36
43
|
// single canonical entry point; the definitions live in lib/core.mjs.
|
|
@@ -56,6 +63,22 @@ const PREVIEW_DIR = join(PACKAGE_ROOT, 'preview');
|
|
|
56
63
|
const VOICE_SRC_DIR = join(PACKAGE_ROOT, 'voice');
|
|
57
64
|
const VOICE_DEST_DIR = join(CONFIG_DIR, 'voice');
|
|
58
65
|
const GHOSTWRITER_VOICE_DIR = join(expandHome('~'), '.claude', 'ghostwriter', 'voice');
|
|
66
|
+
const IMAGE_STYLE_SRC_DIR = join(PACKAGE_ROOT, 'image-style');
|
|
67
|
+
const IMAGE_STYLE_DEST_DIR = join(CONFIG_DIR, 'image-style');
|
|
68
|
+
|
|
69
|
+
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
70
|
+
function isValidPngFile(path) {
|
|
71
|
+
try {
|
|
72
|
+
const buf = readFileSync(path);
|
|
73
|
+
return buf.length >= 8 && buf.subarray(0, 8).equals(PNG_MAGIC);
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function slugFromFile(file) {
|
|
80
|
+
return String(file || '').replace(/\.md$/, '');
|
|
81
|
+
}
|
|
59
82
|
|
|
60
83
|
const log = {
|
|
61
84
|
info: (msg) => console.log(msg),
|
|
@@ -226,7 +249,13 @@ async function promptForProject(defaults = {}) {
|
|
|
226
249
|
validate: VALIDATORS.label,
|
|
227
250
|
},
|
|
228
251
|
{
|
|
229
|
-
type: '
|
|
252
|
+
type: 'confirm',
|
|
253
|
+
name: 'private',
|
|
254
|
+
message: 'Is this repo private? (no GitHub commit links will ever be generated)',
|
|
255
|
+
initial: defaults.private || false,
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
type: (_p, values) => (values.private ? null : 'text'),
|
|
230
259
|
name: 'remote',
|
|
231
260
|
message: 'Project GitHub remote (<owner>/<repo>):',
|
|
232
261
|
initial: (_p, values) => detectProjectRemote(expandHome(values.path)) || initialRemote,
|
|
@@ -244,8 +273,9 @@ async function promptForProject(defaults = {}) {
|
|
|
244
273
|
const out = {
|
|
245
274
|
key: answers.key.trim(),
|
|
246
275
|
path: expandHome(answers.path),
|
|
247
|
-
remote: answers.remote.trim(),
|
|
248
276
|
};
|
|
277
|
+
if (answers.remote && answers.remote.trim()) out.remote = answers.remote.trim();
|
|
278
|
+
if (answers.private) out.private = true;
|
|
249
279
|
if (answers.label && answers.label.trim()) out.label = answers.label.trim();
|
|
250
280
|
const tagPrefix = (answers.tagPrefix || '').trim();
|
|
251
281
|
if (tagPrefix) out.tagPrefix = tagPrefix;
|
|
@@ -376,6 +406,45 @@ async function cmdInit() {
|
|
|
376
406
|
}
|
|
377
407
|
}
|
|
378
408
|
|
|
409
|
+
// Install the bundled cover style guide + font — same install pattern as the voice
|
|
410
|
+
// profile above. Both are needed before any cover image can be composed/rendered.
|
|
411
|
+
if (!existsSync(IMAGE_STYLE_DEST_DIR)) {
|
|
412
|
+
mkdirSync(IMAGE_STYLE_DEST_DIR, { recursive: true, mode: 0o700 });
|
|
413
|
+
}
|
|
414
|
+
const styleGuideSrc = join(IMAGE_STYLE_SRC_DIR, 'style-guide.example.md');
|
|
415
|
+
const styleGuideDest = join(IMAGE_STYLE_DEST_DIR, 'style-guide.md');
|
|
416
|
+
if (existsSync(styleGuideSrc) && (await confirmOverwrite('image-style/style-guide.md', styleGuideDest))) {
|
|
417
|
+
copyFileSync(styleGuideSrc, styleGuideDest);
|
|
418
|
+
log.ok(`Installed image-style/style-guide.md → ${styleGuideDest}`);
|
|
419
|
+
}
|
|
420
|
+
const fontSrc = join(IMAGE_STYLE_SRC_DIR, 'font.ttf');
|
|
421
|
+
const fontDest = join(IMAGE_STYLE_DEST_DIR, 'font.ttf');
|
|
422
|
+
if (existsSync(fontSrc) && (await confirmOverwrite('image-style/font.ttf', fontDest))) {
|
|
423
|
+
copyFileSync(fontSrc, fontDest);
|
|
424
|
+
log.ok(`Installed image-style/font.ttf → ${fontDest}`);
|
|
425
|
+
}
|
|
426
|
+
const iconsSrc = join(IMAGE_STYLE_SRC_DIR, 'icons.md');
|
|
427
|
+
const iconsDest = join(IMAGE_STYLE_DEST_DIR, 'icons.md');
|
|
428
|
+
if (existsSync(iconsSrc) && (await confirmOverwrite('image-style/icons.md', iconsDest))) {
|
|
429
|
+
copyFileSync(iconsSrc, iconsDest);
|
|
430
|
+
log.ok(`Installed image-style/icons.md → ${iconsDest}`);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Cover-generation reachability checks. Informational only — neither failure blocks
|
|
434
|
+
// setup, since a missing Chromium/font only affects cover generation, not the rest of
|
|
435
|
+
// /devlog.
|
|
436
|
+
try {
|
|
437
|
+
const browser = await chromium.launch();
|
|
438
|
+
await browser.close();
|
|
439
|
+
} catch {
|
|
440
|
+
log.warn('Chromium is not installed — cover images will fail to render.');
|
|
441
|
+
log.hint('npx playwright install chromium');
|
|
442
|
+
}
|
|
443
|
+
if (!existsSync(fontDest) || statSync(fontDest).size === 0) {
|
|
444
|
+
log.warn('Cover font is missing or unreadable (0 bytes) — cover images will fail to render.');
|
|
445
|
+
log.hint('Re-run `devlog init` to reinstall it.');
|
|
446
|
+
}
|
|
447
|
+
|
|
379
448
|
log.info('\n' + kleur.bold().green('Setup complete.') + '\n');
|
|
380
449
|
log.info('Next steps:');
|
|
381
450
|
if (config.projects.length === 0) {
|
|
@@ -402,6 +471,7 @@ async function cmdAddProject(rest) {
|
|
|
402
471
|
label: { type: 'string' },
|
|
403
472
|
'tag-prefix': { type: 'string' },
|
|
404
473
|
'path-filter': { type: 'string' },
|
|
474
|
+
private: { type: 'boolean', default: false },
|
|
405
475
|
yes: { type: 'boolean', default: false },
|
|
406
476
|
json: { type: 'boolean', default: false },
|
|
407
477
|
},
|
|
@@ -417,7 +487,9 @@ async function cmdAddProject(rest) {
|
|
|
417
487
|
if (!existsSync(path)) emitJSON({ error: 'path-missing', message: `Path does not exist: ${path}` }, 1);
|
|
418
488
|
const key = values.key || basename(path);
|
|
419
489
|
const remote = values.remote || detectProjectRemote(path);
|
|
420
|
-
|
|
490
|
+
// A private project never links commits publicly, so an undetectable
|
|
491
|
+
// remote isn't fatal for it — only for a project that intends to be public.
|
|
492
|
+
if (!remote && !values.private) emitJSON({ error: 'remote-undetectable', message: 'No origin remote found; pass --remote <owner>/<repo>.' }, 1);
|
|
421
493
|
try {
|
|
422
494
|
const next = addProject(config, {
|
|
423
495
|
key,
|
|
@@ -426,6 +498,7 @@ async function cmdAddProject(rest) {
|
|
|
426
498
|
label: values.label,
|
|
427
499
|
tagPrefix: values['tag-prefix'],
|
|
428
500
|
pathFilter: values['path-filter'],
|
|
501
|
+
private: values.private,
|
|
429
502
|
});
|
|
430
503
|
atomicWriteJSON(CONFIG_PATH, next);
|
|
431
504
|
emitJSON({ ok: true, added: next.projects.at(-1), projects: next.projects.map((p) => p.key) });
|
|
@@ -452,6 +525,7 @@ async function cmdAddProject(rest) {
|
|
|
452
525
|
remote: newProject.remote,
|
|
453
526
|
label: newProject.label,
|
|
454
527
|
tagPrefix: newProject.tagPrefix,
|
|
528
|
+
private: newProject.private,
|
|
455
529
|
});
|
|
456
530
|
atomicWriteJSON(CONFIG_PATH, next);
|
|
457
531
|
log.ok(`Added "${newProject.key}" to config.`);
|
|
@@ -572,18 +646,30 @@ function cmdPublishEntry(rest) {
|
|
|
572
646
|
project: { type: 'string' },
|
|
573
647
|
version: { type: 'string' },
|
|
574
648
|
entry: { type: 'string' },
|
|
649
|
+
cover: { type: 'string' },
|
|
575
650
|
},
|
|
576
651
|
allowPositionals: false,
|
|
577
652
|
});
|
|
578
653
|
for (const flag of ['clone', 'project', 'version', 'entry']) {
|
|
579
654
|
if (!values[flag]) emitJSON({ error: 'missing-flag', message: `publish-entry requires --${flag}` }, 1);
|
|
580
655
|
}
|
|
656
|
+
|
|
657
|
+
let coverImageBuffer;
|
|
658
|
+
if (values.cover) {
|
|
659
|
+
try {
|
|
660
|
+
coverImageBuffer = readFileSync(expandHome(values.cover));
|
|
661
|
+
} catch (e) {
|
|
662
|
+
emitJSON({ error: 'cover-unreadable', message: e.message }, 1);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
581
666
|
try {
|
|
582
667
|
const result = publishEntry({
|
|
583
668
|
cloneDir: expandHome(values.clone),
|
|
584
669
|
project: values.project,
|
|
585
670
|
version: values.version,
|
|
586
671
|
entryPath: expandHome(values.entry),
|
|
672
|
+
...(coverImageBuffer ? { coverImageBuffer } : {}),
|
|
587
673
|
});
|
|
588
674
|
emitJSON({ ok: true, ...result });
|
|
589
675
|
} catch (e) {
|
|
@@ -591,6 +677,345 @@ function cmdPublishEntry(rest) {
|
|
|
591
677
|
}
|
|
592
678
|
}
|
|
593
679
|
|
|
680
|
+
// ─── backfill-covers list ─────────────────────────────────────────────────────
|
|
681
|
+
function cmdBackfillCovers(rest) {
|
|
682
|
+
const sub = rest[0];
|
|
683
|
+
if (sub !== 'list') {
|
|
684
|
+
emitJSON({ error: 'unknown-subcommand', message: 'Usage: devlog backfill-covers list --clone <cloneDir> [--project <key>] [--out <staging-dir>] [--all]' }, 2);
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
const { values } = parseArgs({
|
|
688
|
+
args: rest.slice(1),
|
|
689
|
+
options: {
|
|
690
|
+
clone: { type: 'string' },
|
|
691
|
+
project: { type: 'string' },
|
|
692
|
+
out: { type: 'string' },
|
|
693
|
+
all: { type: 'boolean', default: false },
|
|
694
|
+
},
|
|
695
|
+
allowPositionals: false,
|
|
696
|
+
});
|
|
697
|
+
if (!values.clone) emitJSON({ error: 'missing-flag', message: 'backfill-covers list requires --clone' }, 1);
|
|
698
|
+
const config = readValidConfigOrExit({ json: true });
|
|
699
|
+
const cloneDir = expandHome(values.clone);
|
|
700
|
+
|
|
701
|
+
let merged;
|
|
702
|
+
try {
|
|
703
|
+
merged = mergeManifestEntries(cloneDir, config);
|
|
704
|
+
} catch (e) {
|
|
705
|
+
emitJSON({ error: 'manifest-error', message: e.message }, 1);
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Default (no --all): missing-cover-only, this command's original purpose. With --all,
|
|
710
|
+
// every manifest entry qualifies regardless of cover status — what a cover-quality
|
|
711
|
+
// backfill needs, since every real entry already has cover: true from a prior batch.
|
|
712
|
+
let candidates = merged
|
|
713
|
+
.filter((e) => e && (values.all || !e.cover))
|
|
714
|
+
.map((e) => ({ ...e, _slug: slugFromFile(e.file) }));
|
|
715
|
+
|
|
716
|
+
if (values.project) {
|
|
717
|
+
candidates = candidates.filter((e) => e.project === values.project);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Resume support: skip candidates already validly staged this session.
|
|
721
|
+
if (values.out) {
|
|
722
|
+
const stagingDir = expandHome(values.out);
|
|
723
|
+
candidates = candidates.filter((e) => {
|
|
724
|
+
const p = join(stagingDir, e.project, `${e._slug}.png`);
|
|
725
|
+
return !(existsSync(p) && isValidPngFile(p));
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// (date, project, slug) is the complete candidate-processing sort key — oldest first,
|
|
730
|
+
// ties broken by project then slug alphabetically, since manifest `date` is
|
|
731
|
+
// day-granularity and a same-day, cross-project collision is a real case at this scale.
|
|
732
|
+
candidates.sort((a, b) =>
|
|
733
|
+
String(a.date).localeCompare(String(b.date))
|
|
734
|
+
|| a.project.localeCompare(b.project)
|
|
735
|
+
|| a._slug.localeCompare(b._slug)
|
|
736
|
+
);
|
|
737
|
+
|
|
738
|
+
const out = candidates.map((e) => {
|
|
739
|
+
// Deterministically extract only the `## Shipped` section — never any other section
|
|
740
|
+
// (e.g. `## Changelog`) — so the agent never needs to open the candidate's raw .md.
|
|
741
|
+
let shipped = '';
|
|
742
|
+
try {
|
|
743
|
+
const raw = readFileSync(join(cloneDir, e.project, e.file), 'utf8');
|
|
744
|
+
const { body } = parseFrontmatter(raw);
|
|
745
|
+
const section = splitSections(body).find((s) => s.heading === 'Shipped');
|
|
746
|
+
shipped = section ? section.content.trim() : '';
|
|
747
|
+
} catch { /* best-effort; leave shipped empty if the .md can't be read */ }
|
|
748
|
+
return {
|
|
749
|
+
project: e.project,
|
|
750
|
+
slug: e._slug,
|
|
751
|
+
title: e.title || e._slug,
|
|
752
|
+
date: e.date,
|
|
753
|
+
tags: Array.isArray(e.tags) ? e.tags : [],
|
|
754
|
+
summary: e.summary || '',
|
|
755
|
+
shipped,
|
|
756
|
+
};
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
emitJSON(out);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// ─── cover-context ─────────────────────────────────────────────────────────────
|
|
763
|
+
function cmdCoverContext(rest) {
|
|
764
|
+
const { positionals, values } = parseArgs({
|
|
765
|
+
args: rest,
|
|
766
|
+
options: {
|
|
767
|
+
clone: { type: 'string' },
|
|
768
|
+
staging: { type: 'string' },
|
|
769
|
+
},
|
|
770
|
+
allowPositionals: true,
|
|
771
|
+
});
|
|
772
|
+
const [project, slug] = positionals;
|
|
773
|
+
if (!project || !slug) {
|
|
774
|
+
emitJSON({ error: 'missing-arg', message: 'Usage: devlog cover-context <project> <slug> --clone <cloneDir> [--staging <staging-dir>]' }, 2);
|
|
775
|
+
}
|
|
776
|
+
if (!values.clone) emitJSON({ error: 'missing-flag', message: 'cover-context requires --clone' }, 1);
|
|
777
|
+
|
|
778
|
+
const config = readValidConfigOrExit({ json: true });
|
|
779
|
+
|
|
780
|
+
// `let`, declared outside both try blocks below — NOT `const` inside the first one.
|
|
781
|
+
// Both blocks' emitJSON calls need text/iconCatalog, and a `const` destructure scoped to
|
|
782
|
+
// the first try alone would leave them unreachable (a ReferenceError) inside the second.
|
|
783
|
+
let text, iconCatalog;
|
|
784
|
+
try {
|
|
785
|
+
({ text, iconCatalog } = loadStyleGuide());
|
|
786
|
+
} catch (e) {
|
|
787
|
+
emitJSON({ error: 'style-guide-missing', message: e.message }, 1);
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
try {
|
|
792
|
+
const references = getRecentCovers({
|
|
793
|
+
cloneDir: expandHome(values.clone),
|
|
794
|
+
config,
|
|
795
|
+
stagingDir: values.staging ? expandHome(values.staging) : null,
|
|
796
|
+
n: 3,
|
|
797
|
+
});
|
|
798
|
+
emitJSON({ styleGuide: text, references, iconCatalog });
|
|
799
|
+
} catch (e) {
|
|
800
|
+
// A configured project's manifest.json missing/unparseable: distinct, named error
|
|
801
|
+
// field — never collapsed into an empty references: [] array — but still does not
|
|
802
|
+
// block the rest of publish for the caller.
|
|
803
|
+
emitJSON({ styleGuide: text, references: [], error: 'reference-lookup-failed', message: e.message, iconCatalog });
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// ─── render-cover ──────────────────────────────────────────────────────────────
|
|
808
|
+
function regenerateContactSheet(outDir) {
|
|
809
|
+
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
810
|
+
const projects = readdirSync(outDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort();
|
|
811
|
+
|
|
812
|
+
let body = '';
|
|
813
|
+
for (const project of projects) {
|
|
814
|
+
const files = readdirSync(join(outDir, project)).filter((f) => f.endsWith('.png')).sort();
|
|
815
|
+
if (files.length === 0) continue;
|
|
816
|
+
body += `<h2>${escapeHtml(project)}</h2><div style="display:flex;flex-wrap:wrap;gap:12px;">`;
|
|
817
|
+
for (const f of files) {
|
|
818
|
+
const slug = f.replace(/\.png$/, '');
|
|
819
|
+
body += `<figure style="margin:0;width:320px;"><img src="${escapeHtml(`${project}/${f}`)}" style="width:100%;height:auto;border:1px solid #444;" loading="lazy"><figcaption>${escapeHtml(slug)}</figcaption></figure>`;
|
|
820
|
+
}
|
|
821
|
+
body += '</div>';
|
|
822
|
+
}
|
|
823
|
+
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>devlog cover contact sheet</title></head>` +
|
|
824
|
+
`<body style="font-family:sans-serif;background:#111;color:#eee;padding:24px;">${body || '<p>No covers staged yet.</p>'}</body></html>`;
|
|
825
|
+
writeFileSync(join(outDir, 'index.html'), html);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
async function cmdRenderCover(rest) {
|
|
829
|
+
const { positionals, values } = parseArgs({
|
|
830
|
+
args: rest,
|
|
831
|
+
options: {
|
|
832
|
+
project: { type: 'string' },
|
|
833
|
+
slug: { type: 'string' },
|
|
834
|
+
out: { type: 'string' },
|
|
835
|
+
},
|
|
836
|
+
allowPositionals: true,
|
|
837
|
+
});
|
|
838
|
+
const htmlFile = positionals[0];
|
|
839
|
+
if (!htmlFile) emitJSON({ error: 'missing-arg', message: 'Usage: devlog render-cover <html-file> --project <key> --slug <slug> --out <dir>' }, 2);
|
|
840
|
+
for (const flag of ['project', 'slug', 'out']) {
|
|
841
|
+
if (!values[flag]) emitJSON({ error: 'missing-flag', message: `render-cover requires --${flag}` }, 1);
|
|
842
|
+
}
|
|
843
|
+
if (!RE_PROJECT_KEY.test(values.project) || values.project.includes('..')) {
|
|
844
|
+
emitJSON({ error: 'bad-flag', message: `Invalid --project: ${values.project}` }, 1);
|
|
845
|
+
}
|
|
846
|
+
if (values.slug.includes('/') || values.slug.includes('..') || values.slug === '') {
|
|
847
|
+
emitJSON({ error: 'bad-flag', message: `Invalid --slug: ${values.slug}` }, 1);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const outDir = expandHome(values.out);
|
|
851
|
+
const projectDir = join(outDir, values.project);
|
|
852
|
+
mkdirSync(projectDir, { recursive: true });
|
|
853
|
+
const pngPath = join(projectDir, `${values.slug}.png`);
|
|
854
|
+
|
|
855
|
+
// Idempotent re-run: an existing, valid PNG is left untouched — no re-render.
|
|
856
|
+
if (existsSync(pngPath) && isValidPngFile(pngPath)) {
|
|
857
|
+
regenerateContactSheet(outDir);
|
|
858
|
+
emitJSON({ ok: true, written: pngPath, rendered: false });
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
let html;
|
|
863
|
+
try {
|
|
864
|
+
html = readFileSync(expandHome(htmlFile), 'utf8');
|
|
865
|
+
} catch (e) {
|
|
866
|
+
emitJSON({ error: 'html-unreadable', message: e.message }, 1);
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
let png;
|
|
871
|
+
try {
|
|
872
|
+
png = await renderCoverImage(html, { width: 1600, height: 900 });
|
|
873
|
+
} catch (e) {
|
|
874
|
+
// Render failure (timeout / Chromium missing / font missing) — the HTML source is
|
|
875
|
+
// left in place for debugging, never deleted on failure.
|
|
876
|
+
emitJSON({ error: 'render-failed', message: e.message }, 1);
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
writeFileSync(pngPath, png);
|
|
880
|
+
|
|
881
|
+
// Transient source document — deleted immediately after a successful render only.
|
|
882
|
+
try { unlinkSync(expandHome(htmlFile)); } catch { /* best-effort cleanup */ }
|
|
883
|
+
|
|
884
|
+
regenerateContactSheet(outDir);
|
|
885
|
+
emitJSON({ ok: true, written: pngPath, rendered: true });
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// ─── commit-covers ──────────────────────────────────────────────────────────────
|
|
889
|
+
async function cmdCommitCovers(rest) {
|
|
890
|
+
// --force takes an OPTIONAL value (bare --force = bulk; --force <slug-or-project/slug>
|
|
891
|
+
// = scoped), which node:util's parseArgs cannot express directly — parsed by hand.
|
|
892
|
+
let forcePresent = false;
|
|
893
|
+
let forceArg = null;
|
|
894
|
+
const positionals = [];
|
|
895
|
+
for (let i = 0; i < rest.length; i++) {
|
|
896
|
+
const a = rest[i];
|
|
897
|
+
if (a === '--force') {
|
|
898
|
+
forcePresent = true;
|
|
899
|
+
if (i + 1 < rest.length && !rest[i + 1].startsWith('--')) forceArg = rest[++i];
|
|
900
|
+
} else if (a.startsWith('--')) {
|
|
901
|
+
emitJSON({ error: 'bad-flag', message: `Unknown flag: ${a}` }, 2);
|
|
902
|
+
return;
|
|
903
|
+
} else {
|
|
904
|
+
positionals.push(a);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const stagingDirArg = positionals[0];
|
|
909
|
+
if (!stagingDirArg) emitJSON({ error: 'missing-arg', message: 'Usage: devlog commit-covers <staging-dir> [--force [slug]]' }, 2);
|
|
910
|
+
const stagingDir = expandHome(stagingDirArg);
|
|
911
|
+
if (!existsSync(stagingDir)) emitJSON({ error: 'staging-dir-missing', message: `Staging dir not found: ${stagingDir}` }, 1);
|
|
912
|
+
|
|
913
|
+
const config = readValidConfigOrExit({ json: true });
|
|
914
|
+
|
|
915
|
+
const staged = [];
|
|
916
|
+
for (const d of readdirSync(stagingDir, { withFileTypes: true })) {
|
|
917
|
+
if (!d.isDirectory()) continue;
|
|
918
|
+
for (const f of readdirSync(join(stagingDir, d.name))) {
|
|
919
|
+
if (!f.endsWith('.png')) continue;
|
|
920
|
+
staged.push({ project: d.name, slug: f.replace(/\.png$/, ''), path: join(stagingDir, d.name, f) });
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// commit-covers takes NO --clone flag of any kind — deliberately, not an oversight (see
|
|
925
|
+
// design doc). It always establishes its own fresh clone at commit time, since it
|
|
926
|
+
// routinely runs well after the backfill/review session that produced the staging dir,
|
|
927
|
+
// and reusing an hours-or-days-old clone would risk mutating a manifest that's since
|
|
928
|
+
// moved on.
|
|
929
|
+
const cloneDir = mkdtempSync(join(tmpdir(), 'devlog-commit-covers-'));
|
|
930
|
+
const branch = config.branch || 'main';
|
|
931
|
+
const cloneUrl = `https://github.com/${config.targetRepo}.git`;
|
|
932
|
+
const cloneResult = spawnSync('git', ['clone', '--depth=1', '--branch', branch, cloneUrl, cloneDir], { encoding: 'utf8' });
|
|
933
|
+
if (cloneResult.status !== 0) {
|
|
934
|
+
rmSync(cloneDir, { recursive: true, force: true });
|
|
935
|
+
emitJSON({ error: 'clone-failed', message: cloneResult.stderr || 'git clone failed' }, 1);
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
const summary = { written: [], skipped: [], failed: [], missingManifest: [] };
|
|
940
|
+
let bulkForceOverwriteCount = 0;
|
|
941
|
+
|
|
942
|
+
for (const s of staged) {
|
|
943
|
+
let merged;
|
|
944
|
+
try {
|
|
945
|
+
merged = mergeManifestEntries(cloneDir, config);
|
|
946
|
+
} catch (e) {
|
|
947
|
+
summary.failed.push({ project: s.project, slug: s.slug, message: e.message });
|
|
948
|
+
continue;
|
|
949
|
+
}
|
|
950
|
+
const row = merged.find((e) => e.project === s.project && slugFromFile(e.file) === s.slug);
|
|
951
|
+
|
|
952
|
+
// Missing/shifted manifest row at commit time: `list` and `commit-covers` read against
|
|
953
|
+
// two separately-established clones taken hours or days apart. Distinct from "found a
|
|
954
|
+
// row, and it already has cover" below — this is "no row at all for this slug under
|
|
955
|
+
// this project." Logged and skipped, never aborting the rest of the run.
|
|
956
|
+
if (!row) {
|
|
957
|
+
summary.missingManifest.push(`${s.project}/${s.slug}`);
|
|
958
|
+
continue;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// Scoped force: --force <project>/<slug>, or bare --force <slug> when that slug is
|
|
962
|
+
// staged under only one project (ambiguous otherwise — require the qualified form).
|
|
963
|
+
let forceThis = false;
|
|
964
|
+
if (forcePresent) {
|
|
965
|
+
if (forceArg === null) {
|
|
966
|
+
forceThis = true; // bulk
|
|
967
|
+
if (row.cover) bulkForceOverwriteCount++;
|
|
968
|
+
} else if (forceArg === `${s.project}/${s.slug}`) {
|
|
969
|
+
forceThis = true;
|
|
970
|
+
} else if (forceArg === s.slug) {
|
|
971
|
+
const ambiguous = staged.some((x) => x.slug === forceArg && x.project !== s.project);
|
|
972
|
+
if (ambiguous) {
|
|
973
|
+
summary.failed.push({ project: s.project, slug: s.slug, message: `--force ${forceArg} is ambiguous (staged under multiple projects) — use --force ${s.project}/${s.slug}` });
|
|
974
|
+
continue;
|
|
975
|
+
}
|
|
976
|
+
forceThis = true;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// Pre-filter: skip an already-covered entry without calling addCoverToExistingEntry()
|
|
981
|
+
// at all, UNLESS this exact entry is in scope for --force.
|
|
982
|
+
if (row.cover && !forceThis) {
|
|
983
|
+
summary.skipped.push(`${s.project}/${s.slug}`);
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
try {
|
|
988
|
+
const coverImageBuffer = readFileSync(s.path);
|
|
989
|
+
addCoverToExistingEntry({ cloneDir, project: s.project, slug: s.slug, coverImageBuffer, force: forceThis });
|
|
990
|
+
summary.written.push(`${s.project}/${s.slug}`);
|
|
991
|
+
} catch (e) {
|
|
992
|
+
summary.failed.push({ project: s.project, slug: s.slug, message: e.message });
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
if (summary.written.length > 0) {
|
|
997
|
+
const steps = [
|
|
998
|
+
['add', '.'],
|
|
999
|
+
['commit', '-m', `chore(devlog): add ${summary.written.length} cover image(s)`],
|
|
1000
|
+
];
|
|
1001
|
+
for (const args of steps) {
|
|
1002
|
+
const r = spawnSync('git', ['-C', cloneDir, ...args], { encoding: 'utf8' });
|
|
1003
|
+
if (r.status !== 0) {
|
|
1004
|
+
emitJSON({ ok: false, ...summary, bulkForceOverwriteCount, error: 'git-commit-failed', message: r.stderr }, 1);
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
const push = spawnSync('git', ['-C', cloneDir, 'push', '--no-tags', 'origin', branch], { encoding: 'utf8' });
|
|
1009
|
+
if (push.status !== 0) {
|
|
1010
|
+
emitJSON({ ok: false, ...summary, bulkForceOverwriteCount, error: 'git-push-failed', message: push.stderr }, 1);
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
rmSync(cloneDir, { recursive: true, force: true });
|
|
1016
|
+
emitJSON({ ok: summary.failed.length === 0, ...summary, bulkForceOverwriteCount });
|
|
1017
|
+
}
|
|
1018
|
+
|
|
594
1019
|
// ─── config (view) ───────────────────────────────────────────────────────────
|
|
595
1020
|
async function cmdConfig(rest) {
|
|
596
1021
|
const { values } = parseArgs({
|
|
@@ -646,7 +1071,11 @@ async function cmdConfig(rest) {
|
|
|
646
1071
|
for (const p of config.projects || []) {
|
|
647
1072
|
log.info(` ${kleur.cyan(p.key)}${p.label ? ` (${p.label})` : ''}`);
|
|
648
1073
|
log.info(kleur.dim(` path: ${p.path}`));
|
|
649
|
-
|
|
1074
|
+
if (p.private) {
|
|
1075
|
+
log.info(kleur.dim(` remote: (private — no commit links)${p.remote ? ` [${p.remote}]` : ''}`));
|
|
1076
|
+
} else {
|
|
1077
|
+
log.info(kleur.dim(` remote: github.com/${p.remote}`));
|
|
1078
|
+
}
|
|
650
1079
|
if (p.pathFilter) log.info(kleur.dim(` scope: ${p.pathFilter}/`));
|
|
651
1080
|
log.info(kleur.dim(` tags: ${p.tagPrefix || 'v'}*`));
|
|
652
1081
|
}
|
|
@@ -711,6 +1140,12 @@ Used by the /devlog skill:
|
|
|
711
1140
|
${kleur.cyan('npx @natjswenson/devlog scan [--project <key>]')} JSON plan of new releases needing entries
|
|
712
1141
|
${kleur.cyan('npx @natjswenson/devlog lint-post <file>')} Deterministic post-contract check
|
|
713
1142
|
${kleur.cyan('npx @natjswenson/devlog publish-entry ...')} Copy a drafted entry into the clone + update manifest (never overwrites)
|
|
1143
|
+
${kleur.cyan('npx @natjswenson/devlog cover-context <project> <slug> --clone <dir>')} Style guide + reference-image paths for cover composition
|
|
1144
|
+
${kleur.cyan('npx @natjswenson/devlog render-cover <html> --project <key> --slug <s> --out <dir>')} Rasterize a composed cover to PNG
|
|
1145
|
+
|
|
1146
|
+
Backfilling covers onto existing posts:
|
|
1147
|
+
${kleur.cyan('npx @natjswenson/devlog backfill-covers list --clone <dir> [--out <staging-dir>]')} List posts missing a cover
|
|
1148
|
+
${kleur.cyan('npx @natjswenson/devlog commit-covers <staging-dir> [--force [slug]]')} Publish staged covers to already-published entries
|
|
714
1149
|
|
|
715
1150
|
Preview:
|
|
716
1151
|
${kleur.cyan('npx @natjswenson/devlog preview')} Run a local preview of your published dev log
|
|
@@ -761,6 +1196,18 @@ if (isMain) {
|
|
|
761
1196
|
case 'publish-entry':
|
|
762
1197
|
cmdPublishEntry(rest);
|
|
763
1198
|
break;
|
|
1199
|
+
case 'backfill-covers':
|
|
1200
|
+
cmdBackfillCovers(rest);
|
|
1201
|
+
break;
|
|
1202
|
+
case 'cover-context':
|
|
1203
|
+
cmdCoverContext(rest);
|
|
1204
|
+
break;
|
|
1205
|
+
case 'render-cover':
|
|
1206
|
+
cmdRenderCover(rest);
|
|
1207
|
+
break;
|
|
1208
|
+
case 'commit-covers':
|
|
1209
|
+
cmdCommitCovers(rest);
|
|
1210
|
+
break;
|
|
764
1211
|
case 'config':
|
|
765
1212
|
cmdConfig(rest);
|
|
766
1213
|
break;
|