From 15e2b12dac58b61b5cdbfe20a3e45fb8078ad5f9 Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Wed, 4 Feb 2026 20:47:21 -0800 Subject: [PATCH 1/8] feat(admin): overhaul CMS dashboard UI and fix API integration Rewrite public/index.html as a full CMS dashboard with sidebar tabs (Drafts/Live), split markdown editor with live preview, metadata editing, drag-drop uploads, toast notifications, keyboard shortcuts, and autosave. Fix three bugs: fetchList now sends kind=articles instead of kind=draft, save() sends {slug, title, body, trailers} instead of {slug, message}, and all POST requests include Content-Type: application/json header. Fix CmsService compatibility with npm-published @git-stunts packages: update imports for plumbing ShellRunner and trailer-codec, replace empty-graph dependency with direct git plumbing calls, add missing await on async server handlers, and add SVG MIME type to static server. --- package-lock.json | 100 ++++- public/GitCMS.svg | 1 + public/index.html | 995 +++++++++++++++++++++++++++++++++++------- src/lib/CmsService.js | 78 +++- src/server/index.js | 35 +- 5 files changed, 1004 insertions(+), 205 deletions(-) create mode 100644 public/GitCMS.svg diff --git a/package-lock.json b/package-lock.json index 2bdecf7..3d977ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,27 +26,109 @@ "../git-stunts/cas": { "name": "@git-stunts/cas", "version": "1.0.0", - "license": "Apache-2.0" + "license": "Apache-2.0", + "dependencies": { + "@git-stunts/plumbing": "file:../plumbing", + "cbor-x": "^1.6.0", + "zod": "^3.24.1" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "eslint": "^9.17.0", + "prettier": "^3.4.2", + "vitest": "^2.1.8" + } }, "../git-stunts/empty-graph": { "name": "@git-stunts/empty-graph", - "version": "1.0.0", - "license": "Apache-2.0" + "version": "7.0.0", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@git-stunts/alfred": "^0.4.0", + "@git-stunts/plumbing": "^2.8.0", + "@git-stunts/trailer-codec": "^2.1.1", + "cbor-x": "^1.6.0", + "patch-package": "^8.0.0", + "roaring": "^2.7.0", + "zod": "^3.24.1" + }, + "bin": { + "git-warp": "bin/git-warp", + "warp-graph": "bin/warp-graph.js" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "@git-stunts/docker-guard": "^0.1.0", + "@typescript-eslint/eslint-plugin": "^8.54.0", + "@typescript-eslint/parser": "^8.54.0", + "eslint": "^9.17.0", + "fast-check": "^4.5.3", + "prettier": "^3.4.2", + "typescript": "^5.9.3", + "typescript-eslint": "^8.54.0", + "vitest": "^2.1.8" + }, + "engines": { + "node": ">=20.0.0" + } }, "../git-stunts/plumbing": { "name": "@git-stunts/plumbing", - "version": "1.0.0", - "license": "Apache-2.0" + "version": "2.8.0", + "license": "Apache-2.0", + "dependencies": { + "zod": "^3.24.1" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "@git-stunts/docker-guard": "^0.1.0", + "eslint": "^9.17.0", + "prettier": "^3.4.2", + "vitest": "^3.0.0" + }, + "engines": { + "bun": ">=1.3.5", + "deno": ">=2.0.0", + "node": ">=20.0.0" + } }, "../git-stunts/trailer-codec": { "name": "@git-stunts/trailer-codec", - "version": "1.0.0", - "license": "Apache-2.0" + "version": "2.1.1", + "license": "Apache-2.0", + "dependencies": { + "zod": "^4.3.5" + }, + "devDependencies": { + "@babel/parser": "^7.24.7", + "@eslint/js": "^9.17.0", + "eslint": "^9.17.0", + "prettier": "^3.4.2", + "vitest": "^3.0.0" + }, + "engines": { + "node": ">=20.0.0" + } }, "../git-stunts/vault": { "name": "@git-stunts/vault", "version": "1.0.0", - "license": "Apache-2.0" + "license": "Apache-2.0", + "dependencies": { + "zod": "^4.3.5" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "eslint": "^9.17.0", + "prettier": "^3.4.2", + "vitest": "^2.1.8" + }, + "engines": { + "bun": ">=1.3.5", + "deno": ">=2.0.0", + "node": ">=20.0.0" + } }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", @@ -1208,7 +1290,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1418,7 +1499,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/public/GitCMS.svg b/public/GitCMS.svg new file mode 100644 index 0000000..820d332 --- /dev/null +++ b/public/GitCMS.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/index.html b/public/index.html index 781a77e..4b96664 100644 --- a/public/index.html +++ b/public/index.html @@ -4,17 +4,25 @@ Git CMS Admin + + -
+ +
- - -
- - + + +
+ + +
+ +
- - -
- - +
+ +
- + +
+ Metadata +
+
+ +
+ +
+ Drop files here or + +
+
+
Ready
+ +
+ +

Select an article or create a new one

+
+ + +
+ diff --git a/src/lib/CmsService.js b/src/lib/CmsService.js index 2c5f9e6..bfb929c 100644 --- a/src/lib/CmsService.js +++ b/src/lib/CmsService.js @@ -1,9 +1,8 @@ import GitPlumbing from '@git-stunts/plumbing'; -import EmptyGraph from '@git-stunts/empty-graph'; -import TrailerCodec from '@git-stunts/trailer-codec'; +import { createMessageHelpers } from '@git-stunts/trailer-codec'; import ContentAddressableStore from '@git-stunts/cas'; import Vault from '@git-stunts/vault'; -import ShellRunner from '@git-stunts/plumbing/ShellRunner.js'; +import ShellRunner from '@git-stunts/plumbing/ShellRunner'; /** * @typedef {Object} CmsServiceOptions @@ -28,8 +27,7 @@ export default class CmsService { cwd }); - this.graph = new EmptyGraph({ plumbing: this.plumbing }); - this.codec = new TrailerCodec(); + this.codec = createMessageHelpers(); this.cas = new ContentAddressableStore({ plumbing: this.plumbing }); this.vault = new Vault(); } @@ -42,6 +40,51 @@ export default class CmsService { return `${this.refPrefix}/${kind}/${slug}`; } + /** + * Resolve a ref to its SHA (returns null if ref doesn't exist). + * @private + */ + async _revParse(revision) { + try { + const out = await this.plumbing.execute({ args: ['rev-parse', '--verify', revision] }); + return out.trim() || null; + } catch { + return null; + } + } + + /** + * Update (or create) a ref to point at a new SHA. + * @private + */ + async _updateRef(ref, newSha, oldSha) { + if (oldSha) { + await this.plumbing.execute({ args: ['update-ref', ref, newSha, oldSha] }); + } else { + await this.plumbing.execute({ args: ['update-ref', ref, newSha] }); + } + } + + /** + * Create an empty-tree commit with a message and optional parents. + * @private + */ + async _createCommit({ message, parents = [] }) { + const emptyTree = (await this.plumbing.execute({ args: ['hash-object', '-t', 'tree', '/dev/null'] })).trim(); + const parentArgs = parents.flatMap(p => ['-p', p]); + const sha = (await this.plumbing.execute({ args: ['commit-tree', emptyTree, ...parentArgs], input: message })).trim(); + return sha; + } + + /** + * Read the full commit message for a given SHA. + * @private + */ + async _readCommitMessage(sha) { + const msg = await this.plumbing.execute({ args: ['log', '-1', '--format=%B', sha] }); + return msg.trimEnd(); + } + /** * Lists all articles of a certain kind. */ @@ -69,11 +112,11 @@ export default class CmsService { */ async readArticle({ slug, kind = 'articles' }) { const ref = this._refFor(slug, kind); - const sha = await this.plumbing.revParse({ revision: ref }); + const sha = await this._revParse(ref); if (!sha) throw new Error(`Article not found: ${slug} (${kind})`); - const message = await this.graph.readNode({ sha }); - return { sha, ...this.codec.decode({ message }) }; + const message = await this._readCommitMessage(sha); + return { sha, ...this.codec.decodeMessage(message) }; } /** @@ -81,18 +124,17 @@ export default class CmsService { */ async saveSnapshot({ slug, title, body, trailers = {} }) { const ref = this._refFor(slug, 'articles'); - const parentSha = await this.plumbing.revParse({ revision: ref }); + const parentSha = await this._revParse(ref); const finalTrailers = { ...trailers, status: 'draft', updatedAt: new Date().toISOString() }; - const message = this.codec.encode({ title, body, trailers: finalTrailers }); + const message = this.codec.encodeMessage({ title, body, trailers: finalTrailers }); - const newSha = await this.graph.createNode({ + const newSha = await this._createCommit({ message, parents: parentSha ? [parentSha] : [], - sign: process.env.CMS_SIGN === '1' }); - await this.plumbing.updateRef({ ref, newSha, oldSha: parentSha }); + await this._updateRef(ref, newSha, parentSha); return { ref, sha: newSha, parent: parentSha }; } @@ -103,11 +145,11 @@ export default class CmsService { const draftRef = this._refFor(slug, 'articles'); const pubRef = this._refFor(slug, 'published'); - const targetSha = sha || await this.plumbing.revParse({ revision: draftRef }); + const targetSha = sha || await this._revParse(draftRef); if (!targetSha) throw new Error(`Nothing to publish for ${slug}`); - const oldSha = await this.plumbing.revParse({ revision: pubRef }); - await this.plumbing.updateRef({ ref: pubRef, newSha: targetSha, oldSha }); + const oldSha = await this._revParse(pubRef); + await this._updateRef(pubRef, targetSha, oldSha); return { ref: pubRef, sha: targetSha, prev: oldSha }; } @@ -134,11 +176,11 @@ export default class CmsService { const treeOid = await this.cas.createTree({ manifest }); const ref = `refs/_blog/chunks/${slug}@current`; - const commitSha = await this.graph.createNode({ + const commitSha = await this._createCommit({ message: `asset:${filename}\n\nmanifest: ${treeOid}`, }); - await this.plumbing.updateRef({ ref, newSha: commitSha }); + await this._updateRef(ref, commitSha); return { manifest, treeOid, commitSha }; } diff --git a/src/server/index.js b/src/server/index.js index 74e29c2..8f8260f 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -23,6 +23,7 @@ const MIME_TYPES = { '.png': 'image/png', '.jpg': 'image/jpeg', '.gif': 'image/gif', + '.svg': 'image/svg+xml', }; function serveStatic(req, res) { @@ -77,25 +78,30 @@ async function handler(req, res) { // GET /api/cms/list?kind=articles|published|comments if (req.method === 'GET' && pathname === '/api/cms/list') { const kind = query.kind || 'articles'; - return send(res, 200, cms.listArticles({ kind })); + return send(res, 200, await cms.listArticles({ kind })); } // GET /api/cms/show?slug=xxx&kind=articles if (req.method === 'GET' && pathname === '/api/cms/show') { const { slug, kind } = query; if (!slug) return send(res, 400, { error: 'slug required' }); - return send(res, 200, cms.readArticle({ slug, kind: kind || 'articles' })); + return send(res, 200, await cms.readArticle({ slug, kind: kind || 'articles' })); } // POST /api/cms/snapshot if (req.method === 'POST' && pathname === '/api/cms/snapshot') { let body = ''; req.on('data', (c) => (body += c)); - req.on('end', () => { - const { slug, title, body: content, trailers } = JSON.parse(body || '{}'); - if (!slug || !title) return send(res, 400, { error: 'slug and title required' }); - const result = cms.saveSnapshot({ slug, title, body: content, trailers }); - return send(res, 200, result); + req.on('end', async () => { + try { + const { slug, title, body: content, trailers } = JSON.parse(body || '{}'); + if (!slug || !title) return send(res, 400, { error: 'slug and title required' }); + const result = await cms.saveSnapshot({ slug, title, body: content, trailers }); + return send(res, 200, result); + } catch (err) { + console.error(err); + return send(res, 500, { error: err.message }); + } }); return; } @@ -104,11 +110,16 @@ async function handler(req, res) { if (req.method === 'POST' && pathname === '/api/cms/publish') { let body = ''; req.on('data', (c) => (body += c)); - req.on('end', () => { - const { slug, sha } = JSON.parse(body || '{}'); - if (!slug) return send(res, 400, { error: 'slug required' }); - const result = cms.publishArticle({ slug, sha }); - return send(res, 200, result); + req.on('end', async () => { + try { + const { slug, sha } = JSON.parse(body || '{}'); + if (!slug) return send(res, 400, { error: 'slug required' }); + const result = await cms.publishArticle({ slug, sha }); + return send(res, 200, result); + } catch (err) { + console.error(err); + return send(res, 500, { error: err.message }); + } }); return; } From 538e3c739ef8e49b0dd4d01ea0cf8ff0d8159242 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Fri, 13 Feb 2026 03:23:43 -0800 Subject: [PATCH 2/8] refactor(test): replace git subprocess tests with InMemoryGraphAdapter Add DI seam to CmsService so tests can inject an InMemoryGraphAdapter instead of forking real git subprocesses. Unit tests now run in ~11ms vs hundreds of ms. Real-git smoke tests moved to test/git-e2e.test.js, excluded from default runs. --- bin/git-cms.js | 20 ++- package-lock.json | 8 +- package.json | 5 +- src/lib/CmsService.js | 222 +++++++++++++++++++++++++++++----- src/lib/ContentStatePolicy.js | 64 ++++++++++ src/server/index.js | 38 ++++++ test/git-e2e.test.js | 52 ++++++++ test/git.test.js | 190 ++++++++++++++++++++++++----- test/server.test.js | 64 ++++++++++ vitest.config.js | 12 +- vitest.e2e.config.js | 7 ++ 11 files changed, 610 insertions(+), 72 deletions(-) create mode 100644 src/lib/ContentStatePolicy.js create mode 100644 test/git-e2e.test.js create mode 100644 vitest.e2e.config.js diff --git a/bin/git-cms.js b/bin/git-cms.js index 1599234..15de585 100755 --- a/bin/git-cms.js +++ b/bin/git-cms.js @@ -39,6 +39,24 @@ async function main() { console.log(`Published: ${res.sha} (${res.ref})`); break; } + case 'unpublish': { + const [rawSlug] = args; + if (!rawSlug) throw new Error('Usage: git cms unpublish '); + const slug = canonicalizeSlug(rawSlug); + + const res = await cms.unpublishArticle({ slug }); + console.log(`Unpublished: ${res.sha} (${res.ref})`); + break; + } + case 'revert': { + const [rawSlug] = args; + if (!rawSlug) throw new Error('Usage: git cms revert '); + const slug = canonicalizeSlug(rawSlug); + + const res = await cms.revertArticle({ slug }); + console.log(`Reverted: ${res.sha} (${res.ref})`); + break; + } case 'list': { const items = await cms.listArticles(); if (items.length === 0) console.log('No articles found'); @@ -65,7 +83,7 @@ async function main() { break; } default: - console.log('Usage: git cms '); + console.log('Usage: git cms '); process.exit(1); } } catch (err) { diff --git a/package-lock.json b/package-lock.json index 1d6338f..8aee0b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@git-stunts/alfred": "^0.10.3", "@git-stunts/docker-guard": "^0.1.0", "@git-stunts/git-cas": "^3.0.0", - "@git-stunts/git-warp": "^10.4.2", + "@git-stunts/git-warp": "^10.8.0", "@git-stunts/plumbing": "^2.8.0", "@git-stunts/trailer-codec": "^2.1.1", "@git-stunts/vault": "^1.0.0" @@ -590,9 +590,9 @@ } }, "node_modules/@git-stunts/git-warp": { - "version": "10.4.2", - "resolved": "https://registry.npmjs.org/@git-stunts/git-warp/-/git-warp-10.4.2.tgz", - "integrity": "sha512-omSbE7df+j89lJlrtjtBvG0f6rompdGLt4Xq2w+rHqIZpx+TxUlwzzndGII8RgVOzvUydk9T2LDbWTI0dCASMQ==", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/@git-stunts/git-warp/-/git-warp-10.8.0.tgz", + "integrity": "sha512-wlwez1Fae4GNxGRIF8HZHJMUFvPA8IHPH0ZWHa8jsYyqzU4563yARaaPYxIQUi079lO8u5HuH0y1EORIZpOLFw==", "license": "Apache-2.0", "dependencies": { "@git-stunts/alfred": "^0.4.0", diff --git a/package.json b/package.json index 45f0695..e3d02f5 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "test": "./test/run-docker.sh", "test:setup": "./test/run-setup-tests.sh", "test:local": "vitest run", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "test:git-e2e": "vitest run --config vitest.e2e.config.js test/git-e2e.test.js" }, "author": "James Ross ", "license": "Apache-2.0", @@ -28,7 +29,7 @@ "@git-stunts/alfred": "^0.10.3", "@git-stunts/docker-guard": "^0.1.0", "@git-stunts/git-cas": "^3.0.0", - "@git-stunts/git-warp": "^10.4.2", + "@git-stunts/git-warp": "^10.8.0", "@git-stunts/plumbing": "^2.8.0", "@git-stunts/trailer-codec": "^2.1.1", "@git-stunts/vault": "^1.0.0" diff --git a/src/lib/CmsService.js b/src/lib/CmsService.js index e020635..94cd199 100644 --- a/src/lib/CmsService.js +++ b/src/lib/CmsService.js @@ -5,15 +5,22 @@ import ContentAddressableStore from '@git-stunts/git-cas'; import VaultResolver from './VaultResolver.js'; import ShellRunner from '@git-stunts/plumbing/ShellRunner'; import { + CmsValidationError, canonicalizeKind, canonicalizeSlug, resolveContentIdentity, } from './ContentIdentityPolicy.js'; +import { + STATES, + resolveEffectiveState, + validateTransition, +} from './ContentStatePolicy.js'; /** * @typedef {Object} CmsServiceOptions - * @property {string} cwd - The working directory of the git repo. + * @property {string} [cwd] - The working directory of the git repo. * @property {string} refPrefix - The namespace for git refs (e.g. refs/_blog/dev). + * @property {import('@git-stunts/git-warp').GraphPersistencePort} [graph] - Optional injected graph adapter (skips git subprocess setup). */ /** @@ -23,24 +30,45 @@ export default class CmsService { /** * @param {CmsServiceOptions} options */ - constructor({ cwd, refPrefix }) { - this.cwd = cwd; + constructor({ cwd, refPrefix, graph }) { this.refPrefix = refPrefix.replace(/\/$/, ''); - - // Initialize Lego Blocks with ShellRunner as the substrate - this.plumbing = new GitPlumbing({ - runner: ShellRunner.run, - cwd - }); - this.repo = new GitRepositoryService({ plumbing: this.plumbing }); - - this.graph = new GitGraphAdapter({ plumbing: this.plumbing }); + const helpers = createMessageHelpers({ bodyFormatOptions: { keepTrailingNewline: true } }); this.codec = { decode: helpers.decodeMessage, encode: helpers.encodeMessage }; - this.cas = new ContentAddressableStore({ plumbing: this.plumbing }); - this.vault = new VaultResolver(); + + if (graph) { + // DI mode — caller provides a GraphPersistencePort (e.g. InMemoryGraphAdapter) + this.graph = graph; + this.plumbing = null; + this.repo = null; + this.cas = null; + this.vault = null; + } else { + // Production mode — wire up real git subprocess infrastructure + this.cwd = cwd; + this.plumbing = new GitPlumbing({ + runner: ShellRunner.run, + cwd + }); + this.repo = new GitRepositoryService({ plumbing: this.plumbing }); + this.graph = new GitGraphAdapter({ plumbing: this.plumbing }); + this.cas = new ContentAddressableStore({ plumbing: this.plumbing }); + this.vault = new VaultResolver(); + } + } + + /** + * Routes ref updates to the correct backend. + * @private + */ + async _updateRef({ ref, newSha, oldSha }) { + if (this.repo) { + await this.repo.updateRef({ ref, newSha, oldSha }); + } else { + await this.graph.updateRef(ref, newSha); + } } /** @@ -53,24 +81,70 @@ export default class CmsService { return `${this.refPrefix}/${canonicalKind}/${canonicalSlug}`; } + /** + * Resolves the effective content state for an article. + * @private + * @returns {{ effectiveState: string, draftSha: string|null, pubSha: string|null, draftStatus: string }} + */ + async _resolveArticleState(slug) { + const canonicalSlug = canonicalizeSlug(slug); + const draftRef = this._refFor(canonicalSlug, 'articles'); + const pubRef = this._refFor(canonicalSlug, 'published'); + + const draftSha = await this.graph.readRef(draftRef); + const pubSha = await this.graph.readRef(pubRef); + + let draftStatus = STATES.DRAFT; + if (draftSha) { + const message = await this.graph.showNode(draftSha); + const decoded = this.codec.decode(message); + draftStatus = decoded.trailers?.status || STATES.DRAFT; + } + + const effectiveState = resolveEffectiveState({ draftStatus, pubSha }); + return { effectiveState, draftSha, pubSha, draftStatus }; + } + + /** + * Returns the effective state of an article. + */ + async getArticleState({ slug }) { + const canonicalSlug = canonicalizeSlug(slug); + const state = await this._resolveArticleState(canonicalSlug); + return { slug: canonicalSlug, state: state.effectiveState }; + } + /** * Lists all articles of a certain kind. */ async listArticles({ kind = 'articles' } = {}) { const canonicalKind = canonicalizeKind(kind); const ns = `${this.refPrefix}/${canonicalKind}/`; - const out = await this.plumbing.execute({ - args: ['for-each-ref', ns, '--format=%(refname) %(objectname)'], - }); - return out - .split('\n') - .filter(Boolean) - .map((line) => { - const [ref, sha] = line.split(' '); - const slug = ref.replace(ns, ''); - return { ref, sha, slug }; + if (this.plumbing) { + const out = await this.plumbing.execute({ + args: ['for-each-ref', ns, '--format=%(refname) %(objectname)'], }); + + return out + .split('\n') + .filter(Boolean) + .map((line) => { + const [ref, sha] = line.split(' '); + const slug = ref.replace(ns, ''); + return { ref, sha, slug }; + }); + } + + // DI mode — use graph.listRefs + readRef + const refs = await this.graph.listRefs(ns); + const results = []; + for (const ref of refs) { + const sha = await this.graph.readRef(ref); + const slug = ref.replace(ns, ''); + results.push({ ref, sha, slug }); + } + return results; } /** @@ -93,7 +167,13 @@ export default class CmsService { const identity = resolveContentIdentity({ slug, trailers: safeTrailers }); const ref = this._refFor(identity.slug, 'articles'); const parentSha = await this.graph.readRef(ref); - + + // Guard: validate state transition if article already exists + if (parentSha) { + const { effectiveState } = await this._resolveArticleState(identity.slug); + validateTransition(effectiveState, STATES.DRAFT); + } + const finalTrailers = { ...safeTrailers, contentid: identity.contentId, @@ -108,7 +188,7 @@ export default class CmsService { sign: process.env.CMS_SIGN === '1' }); - await this.repo.updateRef({ ref, newSha, oldSha: parentSha }); + await this._updateRef({ ref, newSha, oldSha: parentSha }); return { ref, sha: newSha, parent: parentSha }; } @@ -119,14 +199,92 @@ export default class CmsService { const canonicalSlug = canonicalizeSlug(slug); const draftRef = this._refFor(canonicalSlug, 'articles'); const pubRef = this._refFor(canonicalSlug, 'published'); - + const targetSha = sha || await this.graph.readRef(draftRef); if (!targetSha) throw new Error(`Nothing to publish for ${canonicalSlug}`); - const oldSha = await this.graph.readRef(pubRef); - await this.repo.updateRef({ ref: pubRef, newSha: targetSha, oldSha }); - - return { ref: pubRef, sha: targetSha, prev: oldSha }; + // Guard: validate state transition + const { effectiveState, pubSha } = await this._resolveArticleState(canonicalSlug); + validateTransition(effectiveState, STATES.PUBLISHED); + + // Idempotent: re-publishing the same SHA is a no-op + if (pubSha === targetSha) { + return { ref: pubRef, sha: targetSha, prev: pubSha }; + } + + await this._updateRef({ ref: pubRef, newSha: targetSha, oldSha: pubSha }); + return { ref: pubRef, sha: targetSha, prev: pubSha }; + } + + /** + * Unpublishes an article: deletes the published ref and marks draft as unpublished. + */ + async unpublishArticle({ slug }) { + const canonicalSlug = canonicalizeSlug(slug); + const draftRef = this._refFor(canonicalSlug, 'articles'); + const pubRef = this._refFor(canonicalSlug, 'published'); + + const { effectiveState, draftSha } = await this._resolveArticleState(canonicalSlug); + validateTransition(effectiveState, STATES.UNPUBLISHED); + + // Delete the published ref + await this.graph.deleteRef(pubRef); + + // Read current draft content and re-commit with status: unpublished + const message = await this.graph.showNode(draftSha); + const decoded = this.codec.decode(message); + const newMessage = this.codec.encode({ + title: decoded.title, + body: decoded.body, + trailers: { ...decoded.trailers, status: STATES.UNPUBLISHED, updatedat: new Date().toISOString() }, + }); + + const newSha = await this.graph.commitNode({ + message: newMessage, + parents: [draftSha], + sign: process.env.CMS_SIGN === '1', + }); + + await this._updateRef({ ref: draftRef, newSha, oldSha: draftSha }); + return { ref: draftRef, sha: newSha, prev: draftSha }; + } + + /** + * Reverts an article to its parent's content, preserving full history. + */ + async revertArticle({ slug }) { + const canonicalSlug = canonicalizeSlug(slug); + const draftRef = this._refFor(canonicalSlug, 'articles'); + + const { effectiveState, draftSha } = await this._resolveArticleState(canonicalSlug); + validateTransition(effectiveState, STATES.REVERTED); + + const info = await this.graph.getNodeInfo(draftSha); + if (!info.parents || info.parents.length === 0 || !info.parents[0]) { + throw new CmsValidationError( + `Cannot revert "${canonicalSlug}": no parent commit exists`, + { code: 'revert_no_parent', field: 'slug' } + ); + } + + const parentCommitSha = info.parents[0]; + const parentMessage = await this.graph.showNode(parentCommitSha); + const parentDecoded = this.codec.decode(parentMessage); + + const newMessage = this.codec.encode({ + title: parentDecoded.title, + body: parentDecoded.body, + trailers: { ...parentDecoded.trailers, status: STATES.REVERTED, updatedat: new Date().toISOString() }, + }); + + const newSha = await this.graph.commitNode({ + message: newMessage, + parents: [draftSha], + sign: process.env.CMS_SIGN === '1', + }); + + await this._updateRef({ ref: draftRef, newSha, oldSha: draftSha }); + return { ref: draftRef, sha: newSha, prev: draftSha }; } /** @@ -156,7 +314,7 @@ export default class CmsService { message: `asset:${filename}\n\nmanifest: ${treeOid}`, }); - await this.repo.updateRef({ ref, newSha: commitSha }); + await this._updateRef({ ref, newSha: commitSha }); return { manifest, treeOid, commitSha }; } diff --git a/src/lib/ContentStatePolicy.js b/src/lib/ContentStatePolicy.js new file mode 100644 index 0000000..432397d --- /dev/null +++ b/src/lib/ContentStatePolicy.js @@ -0,0 +1,64 @@ +/** + * Content state machine policy for git-cms. + * + * Defines the four editorial states and enforces valid transitions. + * + * | Logical State | `status` trailer | `published/` ref | + * |---------------|------------------|------------------------| + * | draft | draft | absent | + * | published | draft | present | + * | unpublished | unpublished | absent | + * | reverted | reverted | absent | + */ + +import { CmsValidationError } from './ContentIdentityPolicy.js'; + +export const CONTENT_STATE_POLICY_VERSION = '1.0.0'; + +export const STATES = Object.freeze({ + DRAFT: 'draft', + PUBLISHED: 'published', + UNPUBLISHED: 'unpublished', + REVERTED: 'reverted', +}); + +/** + * Allowed transitions: from → Set of valid targets. + */ +export const TRANSITIONS = Object.freeze({ + [STATES.DRAFT]: new Set([STATES.DRAFT, STATES.PUBLISHED, STATES.REVERTED]), + [STATES.PUBLISHED]: new Set([STATES.UNPUBLISHED, STATES.PUBLISHED]), + [STATES.UNPUBLISHED]: new Set([STATES.DRAFT, STATES.PUBLISHED]), + [STATES.REVERTED]: new Set([STATES.DRAFT]), +}); + +/** + * Derives the effective state from the draft status trailer and the + * presence/absence of a published ref. + * + * @param {{ draftStatus: string, pubSha: string|null }} params + * @returns {string} One of STATES values. + */ +export function resolveEffectiveState({ draftStatus, pubSha }) { + if (pubSha) return STATES.PUBLISHED; + if (draftStatus === STATES.UNPUBLISHED) return STATES.UNPUBLISHED; + if (draftStatus === STATES.REVERTED) return STATES.REVERTED; + return STATES.DRAFT; +} + +/** + * Guards a state transition. Throws if the transition is not allowed. + * + * @param {string} from - Current effective state. + * @param {string} to - Desired target state. + * @throws {CmsValidationError} with code `invalid_state_transition` + */ +export function validateTransition(from, to) { + const allowed = TRANSITIONS[from]; + if (!allowed || !allowed.has(to)) { + throw new CmsValidationError( + `Cannot transition from "${from}" to "${to}"`, + { code: 'invalid_state_transition', field: 'status' } + ); + } +} diff --git a/src/server/index.js b/src/server/index.js index 4b84159..5c70c44 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -163,6 +163,44 @@ async function handler(req, res) { return; } + // POST /api/cms/unpublish + if (req.method === 'POST' && pathname === '/api/cms/unpublish') { + let body = ''; + req.on('data', (c) => (body += c)); + req.on('end', async () => { + try { + const { slug: rawSlug } = JSON.parse(body || '{}'); + if (!rawSlug) return send(res, 400, { error: 'slug required' }); + const slug = canonicalizeSlug(rawSlug); + const result = await cms.unpublishArticle({ slug }); + return send(res, 200, result); + } catch (err) { + logError(err); + return sendError(res, err); + } + }); + return; + } + + // POST /api/cms/revert + if (req.method === 'POST' && pathname === '/api/cms/revert') { + let body = ''; + req.on('data', (c) => (body += c)); + req.on('end', async () => { + try { + const { slug: rawSlug } = JSON.parse(body || '{}'); + if (!rawSlug) return send(res, 400, { error: 'slug required' }); + const slug = canonicalizeSlug(rawSlug); + const result = await cms.revertArticle({ slug }); + return send(res, 200, result); + } catch (err) { + logError(err); + return sendError(res, err); + } + }); + return; + } + // POST /api/cms/upload if (req.method === 'POST' && pathname === '/api/cms/upload') { let body = ''; diff --git a/test/git-e2e.test.js b/test/git-e2e.test.js new file mode 100644 index 0000000..224fa19 --- /dev/null +++ b/test/git-e2e.test.js @@ -0,0 +1,52 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { execFileSync } from 'node:child_process'; +import CmsService from '../src/lib/CmsService.js'; + +describe('CmsService (E2E — real git)', () => { + let cwd; + let cms; + const refPrefix = 'refs/cms'; + + beforeEach(() => { + cwd = mkdtempSync(path.join(os.tmpdir(), 'git-cms-e2e-')); + execFileSync('git', ['init'], { cwd }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd }); + cms = new CmsService({ cwd, refPrefix }); + }); + + afterEach(() => { + rmSync(cwd, { recursive: true, force: true }); + }); + + it('save → read → publish round-trip against real repo', async () => { + const slug = 'e2e-smoke'; + const title = 'E2E Title'; + const body = 'E2E body content'; + + const saved = await cms.saveSnapshot({ slug, title, body }); + expect(saved.sha).toHaveLength(40); + + const article = await cms.readArticle({ slug }); + expect(article.title).toBe(title); + expect(article.body).toBe(body + '\n'); + expect(article.trailers.status).toBe('draft'); + + await cms.publishArticle({ slug, sha: saved.sha }); + const pub = await cms.readArticle({ slug, kind: 'published' }); + expect(pub.sha).toBe(saved.sha); + }); + + it('propagates underlying git errors while listing', async () => { + const originalExecute = cms.plumbing.execute; + cms.plumbing.execute = async () => { + throw new Error('fatal: permission denied'); + }; + + await expect(cms.listArticles()).rejects.toThrow('fatal: permission denied'); + cms.plumbing.execute = originalExecute; + }); +}); diff --git a/test/git.test.js b/test/git.test.js index e509dfe..2dbc413 100644 --- a/test/git.test.js +++ b/test/git.test.js @@ -1,37 +1,26 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtempSync, rmSync } from 'node:fs'; -import path from 'node:path'; -import os from 'node:os'; -import { execFileSync } from 'node:child_process'; +import { describe, it, expect, beforeEach } from 'vitest'; +import InMemoryGraphAdapter from '@git-stunts/git-warp/InMemoryGraphAdapter'; import CmsService from '../src/lib/CmsService.js'; +import { CmsValidationError } from '../src/lib/ContentIdentityPolicy.js'; describe('CmsService (Integration)', () => { - let cwd; let cms; const refPrefix = 'refs/cms'; beforeEach(() => { - cwd = mkdtempSync(path.join(os.tmpdir(), 'git-cms-service-test-')); - execFileSync('git', ['init'], { cwd }); - execFileSync('git', ['config', 'user.name', 'Test'], { cwd }); - execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd }); - - cms = new CmsService({ cwd, refPrefix }); - }); - - afterEach(() => { - rmSync(cwd, { recursive: true, force: true }); + const graph = new InMemoryGraphAdapter(); + cms = new CmsService({ refPrefix, graph }); }); it('saves a snapshot and reads it back', async () => { const slug = 'hello-world'; const title = 'Title'; const body = 'Body content'; - + const res = await cms.saveSnapshot({ slug, title, body }); - + expect(res.sha).toHaveLength(40); - + const article = await cms.readArticle({ slug }); expect(article.title).toBe(title); expect(article.body).toBe(body + '\n'); @@ -40,12 +29,12 @@ describe('CmsService (Integration)', () => { it('updates an existing article (history)', async () => { const slug = 'history-test'; - + const v1 = await cms.saveSnapshot({ slug, title: 'v1', body: 'b1' }); const v2 = await cms.saveSnapshot({ slug, title: 'v2', body: 'b2' }); - + expect(v2.parent).toBe(v1.sha); - + const article = await cms.readArticle({ slug }); expect(article.title).toBe('v2'); }); @@ -53,7 +42,7 @@ describe('CmsService (Integration)', () => { it('lists articles', async () => { await cms.saveSnapshot({ slug: 'a', title: 'A', body: 'A' }); await cms.saveSnapshot({ slug: 'b', title: 'B', body: 'B' }); - + const list = await cms.listArticles(); expect(list).toHaveLength(2); expect(list.map(i => i.slug).sort()).toEqual(['a', 'b']); @@ -62,9 +51,9 @@ describe('CmsService (Integration)', () => { it('publishes an article', async () => { const slug = 'pub-test'; const { sha } = await cms.saveSnapshot({ slug, title: 'ready', body: '...' }); - + await cms.publishArticle({ slug, sha }); - + const pubArticle = await cms.readArticle({ slug, kind: 'published' }); expect(pubArticle.sha).toBe(sha); }); @@ -98,14 +87,151 @@ describe('CmsService (Integration)', () => { }) ).rejects.toThrow(/must match canonical slug/); }); +}); + +describe('State Machine', () => { + let cms; + const refPrefix = 'refs/cms'; + + beforeEach(() => { + const graph = new InMemoryGraphAdapter(); + cms = new CmsService({ refPrefix, graph }); + }); + + it('draft → draft (re-save) keeps status draft', async () => { + await cms.saveSnapshot({ slug: 'sm-test', title: 'v1', body: 'b1' }); + await cms.saveSnapshot({ slug: 'sm-test', title: 'v2', body: 'b2' }); + const { state } = await cms.getArticleState({ slug: 'sm-test' }); + expect(state).toBe('draft'); + }); + + it('draft → published sets effective state to published', async () => { + await cms.saveSnapshot({ slug: 'sm-pub', title: 'Title', body: 'Body' }); + await cms.publishArticle({ slug: 'sm-pub' }); + const { state } = await cms.getArticleState({ slug: 'sm-pub' }); + expect(state).toBe('published'); + }); + + it('published → unpublished deletes published ref and sets status', async () => { + await cms.saveSnapshot({ slug: 'sm-unpub', title: 'Title', body: 'Body' }); + await cms.publishArticle({ slug: 'sm-unpub' }); + await cms.unpublishArticle({ slug: 'sm-unpub' }); + + const { state } = await cms.getArticleState({ slug: 'sm-unpub' }); + expect(state).toBe('unpublished'); + + // Published ref should be gone + const pubList = await cms.listArticles({ kind: 'published' }); + expect(pubList.find(a => a.slug === 'sm-unpub')).toBeUndefined(); + }); + + it('unpublished → draft via re-save', async () => { + await cms.saveSnapshot({ slug: 'sm-resave', title: 'v1', body: 'b1' }); + await cms.publishArticle({ slug: 'sm-resave' }); + await cms.unpublishArticle({ slug: 'sm-resave' }); + await cms.saveSnapshot({ slug: 'sm-resave', title: 'v2', body: 'b2' }); + + const { state } = await cms.getArticleState({ slug: 'sm-resave' }); + expect(state).toBe('draft'); + }); + + it('unpublished → published via re-publish', async () => { + await cms.saveSnapshot({ slug: 'sm-repub', title: 'v1', body: 'b1' }); + await cms.publishArticle({ slug: 'sm-repub' }); + await cms.unpublishArticle({ slug: 'sm-repub' }); + await cms.publishArticle({ slug: 'sm-repub' }); + + const { state } = await cms.getArticleState({ slug: 'sm-repub' }); + expect(state).toBe('published'); + }); + + it('reverted → draft via re-save', async () => { + await cms.saveSnapshot({ slug: 'sm-rev-resave', title: 'v1', body: 'b1' }); + await cms.saveSnapshot({ slug: 'sm-rev-resave', title: 'v2', body: 'b2' }); + await cms.revertArticle({ slug: 'sm-rev-resave' }); + + const { state: revertedState } = await cms.getArticleState({ slug: 'sm-rev-resave' }); + expect(revertedState).toBe('reverted'); + + await cms.saveSnapshot({ slug: 'sm-rev-resave', title: 'v3', body: 'b3' }); + const { state } = await cms.getArticleState({ slug: 'sm-rev-resave' }); + expect(state).toBe('draft'); + }); + + it('revert creates new commit with parent content, preserves history', async () => { + const v1 = await cms.saveSnapshot({ slug: 'sm-rev-content', title: 'Original', body: 'original body' }); + await cms.saveSnapshot({ slug: 'sm-rev-content', title: 'Edited', body: 'edited body' }); + const reverted = await cms.revertArticle({ slug: 'sm-rev-content' }); + + // New SHA, not the same as v1 + expect(reverted.sha).not.toBe(v1.sha); + + const article = await cms.readArticle({ slug: 'sm-rev-content' }); + expect(article.title).toBe('Original'); + expect(article.body).toContain('original body'); + expect(article.trailers.status).toBe('reverted'); + }); + + it('revert with no parent throws revert_no_parent', async () => { + await cms.saveSnapshot({ slug: 'sm-no-parent', title: 'First', body: 'only' }); + + await expect(cms.revertArticle({ slug: 'sm-no-parent' })).rejects.toThrow( + /no parent commit exists/ + ); + + try { + await cms.revertArticle({ slug: 'sm-no-parent' }); + } catch (err) { + expect(err).toBeInstanceOf(CmsValidationError); + expect(err.code).toBe('revert_no_parent'); + } + }); + + it('cannot unpublish a draft', async () => { + await cms.saveSnapshot({ slug: 'sm-bad-unpub', title: 'T', body: 'B' }); + + try { + await cms.unpublishArticle({ slug: 'sm-bad-unpub' }); + expect.fail('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(CmsValidationError); + expect(err.code).toBe('invalid_state_transition'); + } + }); + + it('cannot revert a published article', async () => { + await cms.saveSnapshot({ slug: 'sm-bad-rev', title: 'T', body: 'B' }); + await cms.publishArticle({ slug: 'sm-bad-rev' }); + + try { + await cms.revertArticle({ slug: 'sm-bad-rev' }); + expect.fail('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(CmsValidationError); + expect(err.code).toBe('invalid_state_transition'); + } + }); + + it('cannot publish a reverted article', async () => { + await cms.saveSnapshot({ slug: 'sm-bad-pub-rev', title: 'v1', body: 'b1' }); + await cms.saveSnapshot({ slug: 'sm-bad-pub-rev', title: 'v2', body: 'b2' }); + await cms.revertArticle({ slug: 'sm-bad-pub-rev' }); + + try { + await cms.publishArticle({ slug: 'sm-bad-pub-rev' }); + expect.fail('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(CmsValidationError); + expect(err.code).toBe('invalid_state_transition'); + } + }); - it('propagates underlying git errors while listing', async () => { - const originalExecute = cms.plumbing.execute; - cms.plumbing.execute = async () => { - throw new Error('fatal: permission denied'); - }; + it('publish is idempotent (same SHA)', async () => { + await cms.saveSnapshot({ slug: 'sm-idem', title: 'T', body: 'B' }); + const first = await cms.publishArticle({ slug: 'sm-idem' }); + const second = await cms.publishArticle({ slug: 'sm-idem' }); - await expect(cms.listArticles()).rejects.toThrow('fatal: permission denied'); - cms.plumbing.execute = originalExecute; + expect(second.sha).toBe(first.sha); + expect(second.prev).toBe(first.sha); }); }); diff --git a/test/server.test.js b/test/server.test.js index 1981909..1bf04ae 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -163,4 +163,68 @@ describe('Server API (Integration)', () => { const res = await fetch(`${baseUrl}/_test-secret-link.txt`); expect(res.status).toBe(404); }); + + it('unpublishes an article via POST /api/cms/unpublish', async () => { + // Create and publish first + await fetch(`${baseUrl}/api/cms/snapshot`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slug: 'srv-unpub', title: 'T', body: 'B' }), + }); + await fetch(`${baseUrl}/api/cms/publish`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slug: 'srv-unpub' }), + }); + + const res = await fetch(`${baseUrl}/api/cms/unpublish`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slug: 'srv-unpub' }), + }); + const data = await res.json(); + expect(res.status).toBe(200); + expect(data.sha).toBeDefined(); + }); + + it('reverts an article via POST /api/cms/revert', async () => { + // Create two versions so there is a parent + await fetch(`${baseUrl}/api/cms/snapshot`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slug: 'srv-revert', title: 'v1', body: 'b1' }), + }); + await fetch(`${baseUrl}/api/cms/snapshot`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slug: 'srv-revert', title: 'v2', body: 'b2' }), + }); + + const res = await fetch(`${baseUrl}/api/cms/revert`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slug: 'srv-revert' }), + }); + const data = await res.json(); + expect(res.status).toBe(200); + expect(data.sha).toBeDefined(); + }); + + it('returns 400 with invalid_state_transition for bad transitions', async () => { + // Create a draft, then try to unpublish it (invalid: draft → unpublished) + await fetch(`${baseUrl}/api/cms/snapshot`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slug: 'srv-bad-trans', title: 'T', body: 'B' }), + }); + + const res = await fetch(`${baseUrl}/api/cms/unpublish`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slug: 'srv-bad-trans' }), + }); + const data = await res.json(); + expect(res.status).toBe(400); + expect(data.code).toBe('invalid_state_transition'); + }); }); diff --git a/vitest.config.js b/vitest.config.js index b5087aa..bc43cf5 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -1,7 +1,17 @@ import { defineConfig, defaultExclude } from 'vitest/config'; +import { fileURLToPath } from 'node:url'; +import { resolve, dirname } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); export default defineConfig({ + resolve: { + alias: { + '@git-stunts/git-warp/InMemoryGraphAdapter': + resolve(__dirname, 'node_modules/@git-stunts/git-warp/src/infrastructure/adapters/InMemoryGraphAdapter.js'), + }, + }, test: { - exclude: [...defaultExclude, 'test/e2e/**'], + exclude: [...defaultExclude, 'test/e2e/**', 'test/git-e2e*'], }, }); diff --git a/vitest.e2e.config.js b/vitest.e2e.config.js new file mode 100644 index 0000000..b5087aa --- /dev/null +++ b/vitest.e2e.config.js @@ -0,0 +1,7 @@ +import { defineConfig, defaultExclude } from 'vitest/config'; + +export default defineConfig({ + test: { + exclude: [...defaultExclude, 'test/e2e/**'], + }, +}); From 12155e9d8715f27253bf401debf24e3cae11e82b Mon Sep 17 00:00:00 2001 From: CI Bot Date: Fri, 13 Feb 2026 03:36:06 -0800 Subject: [PATCH 3/8] docs: add CHANGELOG covering git-stunts branch work --- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b60ad03 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,33 @@ +# Changelog + +All notable changes to git-cms are documented in this file. + +## [Unreleased] — git-stunts branch + +### Added +- **Content Identity Policy (M1.1):** Canonical slug validation with NFKC normalization, reserved word rejection, and `CmsValidationError` contract (`ContentIdentityPolicy.js`) +- **State Machine (M1.2):** Explicit draft/published/unpublished/reverted states with enforced transition rules (`ContentStatePolicy.js`) +- **Admin UI overhaul:** Split/edit/preview markdown editor (via `marked`), autosave, toast notifications, skeleton loading, drag-and-drop file uploads, metadata trailer editor, keyboard shortcuts (`Cmd+S`, `Esc`), dark mode token system +- **DI seam in CmsService:** Optional `graph` constructor param enables `InMemoryGraphAdapter` injection for zero-subprocess tests +- **In-memory test adapter:** Unit tests run in ~11ms instead of hundreds of ms (no `git init`/subprocess forks) +- **E2E test separation:** Real-git smoke tests in `test/git-e2e.test.js`, excluded from default `test:local` runs +- **`test:git-e2e` script:** Run real-git integration tests independently +- **`@git-stunts/alfred` dependency:** Resilience policy library (wired but not yet integrated) +- **`@git-stunts/docker-guard` dependency:** Docker isolation helpers +- **ROADMAP.md:** M0–M6 milestone plan with blocking graph +- **Formal LaTeX ADR** (`docs/adr-tex-2/`) +- **Onboarding scripts:** `setup.sh`, `demo.sh`, `quickstart.sh` with interactive menus +- **Dependency integrity check:** `check-dependency-integrity.mjs` prevents `file:` path regressions + +### Changed +- CmsService now uses `@git-stunts/git-warp` `GitGraphAdapter` and `@git-stunts/plumbing` `GitRepositoryService` instead of raw plumbing calls +- All `repo.updateRef()` calls routed through `CmsService._updateRef()` for DI/production dual-path +- `listArticles()` supports both plumbing (`for-each-ref`) and in-memory (`graph.listRefs`) paths +- Server endpoints return structured `{ code, field }` errors for validation failures +- Swapped all `file:` dependency paths to versioned npm ranges (PP3) + +### Fixed +- Symlink traversal hardening in static file serving +- Slug canonicalization enforced at all API ingress points +- Admin UI API calls aligned with server contract (query params, response shapes) +- Server integration test environment stabilized for CI From 924184313a07cda1d7e0714502c790e62ef937c6 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Fri, 13 Feb 2026 03:47:08 -0800 Subject: [PATCH 4/8] docs: fix remaining PR review nits - Add inline descriptions for demo/quickstart scripts in README - Correct "all scripts are tested" claim in scripts/README (only setup.sh has BATS coverage) - Convert bare file paths to proper markdown links --- README.md | 4 ++-- scripts/README.md | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 940038c..509da87 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,10 @@ npm run setup ### Try It Out ```bash -# Option 1: See a demo (recommended first time) +# Option 1: Guided walkthrough of key features npm run demo -# Option 2: Interactive menu +# Option 2: Interactive menu (start server, run tests, open shell) npm run quickstart # Option 3: Just start the server diff --git a/scripts/README.md b/scripts/README.md index 5f1866b..e5bd2d1 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -2,7 +2,7 @@ Helper scripts for working with git-cms safely. -**All scripts are tested!** See `test/setup.bats` for the test suite. +`setup.sh` is tested via `test/setup.bats`. Other scripts are manually verified. ## Available Scripts @@ -88,6 +88,6 @@ npm test ## More Info -- Getting Started Guide: `docs/GETTING_STARTED.md` -- Architecture Decision Record: `docs/ADR.md` -- Main README: `README.md` +- [Getting Started Guide](../docs/GETTING_STARTED.md) +- [Architecture Decision Record](../docs/ADR.md) +- [Main README](../README.md) From 36459209ed9a26e94b4d2a04115765e7e216adb6 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Fri, 13 Feb 2026 04:25:23 -0800 Subject: [PATCH 5/8] chore: clean up InMemoryGraphAdapter vitest alias Rename alias to #test/InMemoryGraphAdapter to signal it's a test-only shim. Add TODO noting the upstream gap (not in git-warp's exports map as of v10.8.0). Simplify URL resolution. --- test/git.test.js | 2 +- vitest.config.js | 10 ++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/test/git.test.js b/test/git.test.js index 2dbc413..4dbfb5f 100644 --- a/test/git.test.js +++ b/test/git.test.js @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import InMemoryGraphAdapter from '@git-stunts/git-warp/InMemoryGraphAdapter'; +import InMemoryGraphAdapter from '#test/InMemoryGraphAdapter'; import CmsService from '../src/lib/CmsService.js'; import { CmsValidationError } from '../src/lib/ContentIdentityPolicy.js'; diff --git a/vitest.config.js b/vitest.config.js index bc43cf5..0f4e779 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -1,14 +1,12 @@ import { defineConfig, defaultExclude } from 'vitest/config'; -import { fileURLToPath } from 'node:url'; -import { resolve, dirname } from 'node:path'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); export default defineConfig({ resolve: { alias: { - '@git-stunts/git-warp/InMemoryGraphAdapter': - resolve(__dirname, 'node_modules/@git-stunts/git-warp/src/infrastructure/adapters/InMemoryGraphAdapter.js'), + // TODO: remove once @git-stunts/git-warp exports InMemoryGraphAdapter publicly + // (not in the package's "exports" map as of v10.8.0) + '#test/InMemoryGraphAdapter': + new URL('node_modules/@git-stunts/git-warp/src/infrastructure/adapters/InMemoryGraphAdapter.js', import.meta.url).pathname, }, }, test: { From dbcb406eff7866fb553cd947769f75c118f58fa7 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Fri, 13 Feb 2026 17:59:47 -0800 Subject: [PATCH 6/8] fix: address PR #3 review feedback (XSS, atomicity, guards, test cleanup) Fixes 20 actionable items from CodeRabbit/Codex review: DOMPurify for stored XSS, unpublish atomicity reorder, null guards, SRI hashes, unknown-status throw, Promise.all for N+1, and test improvements. --- CHANGELOG.md | 12 ++++++ public/index.html | 54 ++++++++++++++++-------- src/lib/CmsService.js | 38 ++++++++++++----- src/lib/ContentStatePolicy.js | 6 ++- test/git-e2e.test.js | 7 +++- test/git.test.js | 78 +++++++++++++++++++---------------- 6 files changed, 129 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b60ad03..c4aa7e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,3 +31,15 @@ All notable changes to git-cms are documented in this file. - Slug canonicalization enforced at all API ingress points - Admin UI API calls aligned with server contract (query params, response shapes) - Server integration test environment stabilized for CI +- **(P1) Stored XSS via markdown preview:** Sanitize `marked.parse()` output with DOMPurify +- **(P1) Unpublish atomicity:** Reorder `unpublishArticle` so draft ref updates before published ref deletion +- **(P2) XSS via slug/badge rendering:** Use `textContent` and DOM APIs instead of `innerHTML` interpolation +- **(P2) SRI hashes:** Add `integrity` + `crossorigin` to marked and DOMPurify CDN script tags +- **(P2) Null guards:** `revertArticle` and `unpublishArticle` throw `no_draft` when draft ref is missing +- **(P2) uploadAsset DI guard:** Throw `unsupported_in_di_mode` when `cas`/`vault` are null +- **(P2) Monkey-patch safety:** E2E test restores `plumbing.execute` in `finally` block +- Unknown `draftStatus` in `resolveEffectiveState` now throws `unknown_status` instead of silently falling through to draft +- Removed double-canonicalization in `_resolveArticleState` +- Replaced sequential `readRef` loop with `Promise.all` in `listArticles` DI path +- Admin UI: fixed `removeTrailerRow` redundant positional removal, FileReader error handling, autosave-while-saving guard, Escape key scoped to editor panel, drag-and-drop scoped to drop zone +- Test cleanup: extracted `createTestCms()` helper, converted try/catch assertions to `.rejects.toMatchObject()`, added guard-path tests diff --git a/public/index.html b/public/index.html index 4b96664..d0e5240 100644 --- a/public/index.html +++ b/public/index.html @@ -4,7 +4,12 @@ Git CMS Admin - + +