Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,34 +110,36 @@ exact corresponding diffs in a deliberate order.

## Source map

- `src/format.ts`: Zod schemas for the machine-owned capture and the author-edited
- `src/format/types.ts`: independent TypeScript types used by internal logic.
- `src/format/schema.ts`: boundary-only Zod schemas for the machine-owned capture and the author-edited
explanations, plus the version 1 ExplainDocument and its optional attribution metadata.
- `src/authoring/git.ts`: captures staged, unstaged, deleted, renamed, and untracked UTF-8
files from an immutable Git base commit, optionally reading the index or limiting the
capture to named paths.
- `src/authoring/capture.ts`: derives change blocks and the content `captureId`, and
materializes exact section patches from capture plus explanations.
- `src/authoring/explanations.ts`: strict safe YAML 1.2 parsing into the explanations schema.
- `src/cli/commands/`: one typed handler module per CLI command.
- `src/authoring/input.ts`: shared capture and explanations path resolution, schemas,
- `src/cli/explanations.ts`: strict safe YAML 1.2 parsing into the explanations schema.
- `src/cli/commands/`: each command owns its option schema and validates inputs before
calling internal logic. `cli.ts` registers commands and forwards their arguments.
- `src/cli/input.ts`: shared capture and explanations path resolution, schemas,
validation, and persistence.
- `src/authoring/walk.ts`: timestamped walk IDs, per-walk paths, listing and deleting
walks, and the current-walk pointer.
- `src/authoring/config.ts`: the optional project-level `.diffwalk/config.json` lookup and
- `src/cli/config.ts`: the optional project-level `.diffwalk/config.json` lookup and
schema for the review service origin.
- `src/authoring/published.ts`: the locally retained published review (ID, URL, service,
- `src/cli/published.ts`: the locally retained published review (ID, URL, service,
and revocation token), written by `publish` and read by `publish --update`.
- `src/cli.ts`: executable entry point for `inspect`, `walks`, `use`, `delete`,
`changes`, `change`, `file`, `check`, `view`, `export`, `publish`, and `unpublish`.
- `src/report/view.ts`: loopback-only report preview server and default-browser launch.
- `src/report/patches.ts`: shared Pierre parse seam used by the generator, the browser
client, and tests.
- `src/report/markdown.ts`: Markdown rendering with inline HTML passed through.
- `src/report.ts`: atomic report writes and client-bundle loading.
- `src/report/index.ts`: atomic report writes and client-bundle loading.
- `src/report/render.ts`: the one report shell, embedded-data escaping, and shell styles,
rendered with inlined assets for the offline file or linked assets for the hosted page.
- `src/publish.ts`: review service origin checks, publish credential lookup, the
publish, update, and unpublish requests, and adding the Git user name as
- `src/cli/service.ts`: review service configuration and origin checks.
- `src/publish/client.ts`: publish, update, and unpublish requests, and adding the Git user name as
`metadata.publishedBy` without mutating the authoring files.
- `src/report/client.ts`: browser entry that mounts a `FileDiff` per file and switches
unified/split through `setOptions`.
Expand Down
83 changes: 52 additions & 31 deletions infra/setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,38 +18,45 @@ set -euo pipefail
BUCKET="${R2_BUCKET:-diffwalk-reports}"
API="https://api.cloudflare.com/client/v4"

for tool in curl jq npx; do
command -v "$tool" >/dev/null || { echo "Missing required tool: $tool" >&2; exit 1; }
done
: "${CLOUDFLARE_API_TOKEN:?Set CLOUDFLARE_API_TOKEN}"
: "${CLOUDFLARE_ZONE_ID:?Set CLOUDFLARE_ZONE_ID}"
main() {
validate_prerequisites
create_report_bucket
disable_public_bucket_url
configure_managed_rules
configure_publish_rate_limit

wrangler() { npx wrangler "$@"; }
echo
echo "Zone configuration is up to date. Deploy the Worker with \`pnpm deploy\`."
}

cloudflare_api() {
local method=$1 path=$2 body=$3
curl --silent --show-error --fail-with-body \
--request "$method" \
--header "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
--header "Content-Type: application/json" \
--data "$body" \
"${API}${path}"
validate_prerequisites() {
local tool
for tool in curl jq npx; do
command -v "$tool" >/dev/null || { echo "Missing required tool: $tool" >&2; exit 1; }
done
: "${CLOUDFLARE_API_TOKEN:?Set CLOUDFLARE_API_TOKEN}"
: "${CLOUDFLARE_ZONE_ID:?Set CLOUDFLARE_ZONE_ID}"
}

echo "==> R2 bucket ${BUCKET}"
if wrangler r2 bucket info "$BUCKET" >/dev/null 2>&1; then
echo " already exists"
else
wrangler r2 bucket create "$BUCKET"
fi
create_report_bucket() {
echo "==> R2 bucket ${BUCKET}"
if wrangler r2 bucket info "$BUCKET" >/dev/null 2>&1; then
echo " already exists"
else
wrangler r2 bucket create "$BUCKET"
fi
}

# Reports are served only through the Worker, so the bucket must never answer directly.
echo "==> Disabling the r2.dev public URL"
wrangler r2 bucket dev-url disable "$BUCKET" --force >/dev/null
wrangler r2 bucket dev-url get "$BUCKET"
disable_public_bucket_url() {
echo "==> Disabling the r2.dev public URL"
wrangler r2 bucket dev-url disable "$BUCKET" --force >/dev/null
wrangler r2 bucket dev-url get "$BUCKET"
}

echo "==> WAF managed rules"
cloudflare_api PUT "/zones/${CLOUDFLARE_ZONE_ID}/rulesets/phases/http_request_firewall_managed/entrypoint" '{
configure_managed_rules() {
echo "==> WAF managed rules"
cloudflare_api PUT "/zones/${CLOUDFLARE_ZONE_ID}/rulesets/phases/http_request_firewall_managed/entrypoint" '{
"rules": [
{
"action": "execute",
Expand All @@ -59,12 +66,14 @@ cloudflare_api PUT "/zones/${CLOUDFLARE_ZONE_ID}/rulesets/phases/http_request_fi
}
]
}' | jq -e '.success' >/dev/null
echo " Cloudflare Free Managed Ruleset deployed"
echo " Cloudflare Free Managed Ruleset deployed"
}

# The Free plan provides one rate limiting rule. Spend it on anonymous writes; report reads
# remain protected by Cloudflare's network-level DDoS mitigation.
echo "==> Publish rate limit"
cloudflare_api PUT "/zones/${CLOUDFLARE_ZONE_ID}/rulesets/phases/http_ratelimit/entrypoint" '{
configure_publish_rate_limit() {
echo "==> Publish rate limit"
cloudflare_api PUT "/zones/${CLOUDFLARE_ZONE_ID}/rulesets/phases/http_ratelimit/entrypoint" '{
"rules": [
{
"action": "block",
Expand All @@ -79,7 +88,19 @@ cloudflare_api PUT "/zones/${CLOUDFLARE_ZONE_ID}/rulesets/phases/http_ratelimit/
}
]
}' | jq -e '.success' >/dev/null
echo " publish and revoke limited to 5 per 10 seconds per IP"
echo " publish and revoke limited to 5 per 10 seconds per IP"
}

wrangler() { npx wrangler "$@"; }

cloudflare_api() {
local method=$1 path=$2 body=$3
curl --silent --show-error --fail-with-body \
--request "$method" \
--header "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
--header "Content-Type: application/json" \
--data "$body" \
"${API}${path}"
}

echo
echo "Zone configuration is up to date. Deploy the Worker with \`pnpm deploy\`."
main
36 changes: 20 additions & 16 deletions scripts/dev-report.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { renderReport } from '../src/report/render'
import { explainDocumentSchema } from '../src/format'
import { explainDocumentSchema } from '../src/format/schema'
import sample from '../fixtures/report-preview.json'

const doc = explainDocumentSchema.parse(sample)
const document = explainDocumentSchema.parse(sample)

const server = Bun.serve({
hostname: '0.0.0.0',
Expand All @@ -11,21 +11,25 @@ const server = Bun.serve({
if (new URL(request.url).pathname !== '/') {
return new Response('Not found', { status: 404 })
}
const build = await Bun.build({
entrypoints: [new URL('../src/report/client.ts', import.meta.url).pathname],
target: 'browser',
format: 'iife',
minify: true,
})
if (!build.success) {
console.error(build.logs)
return new Response('Report client build failed', { status: 500 })
}
const client = await build.outputs[0]!.text()
return new Response(renderReport(doc, client), {
headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' },
})
return previewReport()
},
})

console.log(`Report preview: http://localhost:${server.port}/ (LAN: http://192.168.88.8:${server.port}/)`)

async function previewReport(): Promise<Response> {
const build = await Bun.build({
entrypoints: [new URL('../src/report/client.ts', import.meta.url).pathname],
target: 'browser',
format: 'iife',
minify: true,
})
if (!build.success) {
console.error(build.logs)
return new Response('Report client build failed', { status: 500 })
}
const clientBundle = await build.outputs[0]!.text()
return new Response(renderReport(document, clientBundle), {
headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' },
})
}
90 changes: 65 additions & 25 deletions scripts/release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,23 +21,15 @@ and the npm version afterward.`);
}

function prepareRelease(version) {
if (!isStable(version)) throw new Error('Usage: pnpm release <stable version>, e.g. pnpm release 0.1.8');
if (git('status', '--porcelain')) throw new Error('Commit or stash working-tree changes before preparing a release.');
run('gh', ['auth', 'status']);
if (run('gh', ['api', 'user', '--jq', '.login']).trim() !== 'claudecafe') {
throw new Error('GitHub CLI must be authenticated as claudecafe.');
}
validateReleaseVersion(version);
validateCleanTree('preparing a release');
validateGitHubAccount();
run('git', ['fetch', 'origin', 'main']);
const current = packageVersion('origin/main');
if (!isStable(current) || compareVersions(version, current) <= 0) {
throw new Error(`Release version must be newer than ${current}.`);
}
validateNewerVersion(version, current);
const tag = `v${version}`;
const branch = `release/${tag}`;
if (git('tag', '--list', tag) || git('branch', '--list', branch) ||
git('ls-remote', 'origin', `refs/tags/${tag}`, `refs/heads/${branch}`)) {
throw new Error(`${tag} or ${branch} already exists.`);
}
validateNewReleaseBranch(tag, branch);

run('git', ['switch', '-c', branch, 'origin/main']);
const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
Expand All @@ -53,30 +45,78 @@ function prepareRelease(version) {
console.log(`${url}\nAfter CI passes and this PR is merged, run pnpm release:publish <PR number>.`);
}

function publishRelease(number) {
if (!/^[1-9]\d*$/.test(number ?? '')) throw new Error('Usage: pnpm release:publish <PR number>');
if (git('status', '--porcelain')) throw new Error('Commit or stash working-tree changes before publishing.');
function publishRelease(prNumber) {
validatePrNumber(prNumber);
validateCleanTree('publishing');
run('gh', ['auth', 'status']);
const pr = JSON.parse(run('gh', ['pr', 'view', number, '--json', 'state,baseRefName,headRefName,mergeCommit']));
const pr = JSON.parse(run('gh', ['pr', 'view', prNumber, '--json', 'state,baseRefName,headRefName,mergeCommit']));
validateMergedReleasePr(pr);
const version = pr.headRefName.replace(/^release\/v/, '');
validateReleaseBranch(pr.headRefName, version);
run('git', ['fetch', 'origin', 'main']);
const commit = pr.mergeCommit.oid;
validateReleaseCommit(commit, version);
const tag = `v${version}`;
validateNewReleaseTag(tag);
run('git', ['tag', tag, commit]);
run('git', ['push', 'origin', `refs/tags/${tag}`]);
console.log(`Pushed ${tag} at ${commit}. Check the Publish workflow in GitHub Actions.`);
}

function validateReleaseVersion(version) {
if (!isStable(version)) throw new Error('Usage: pnpm release <stable version>, e.g. pnpm release 0.1.8');
}

function validateCleanTree(operation) {
if (git('status', '--porcelain')) throw new Error(`Commit or stash working-tree changes before ${operation}.`);
}

function validateGitHubAccount() {
run('gh', ['auth', 'status']);
if (run('gh', ['api', 'user', '--jq', '.login']).trim() !== 'claudecafe') {
throw new Error('GitHub CLI must be authenticated as claudecafe.');
}
}

function validateNewerVersion(version, current) {
if (!isStable(current) || compareVersions(version, current) <= 0) {
throw new Error(`Release version must be newer than ${current}.`);
}
}

function validateNewReleaseBranch(tag, branch) {
if (git('tag', '--list', tag) || git('branch', '--list', branch) ||
git('ls-remote', 'origin', `refs/tags/${tag}`, `refs/heads/${branch}`)) {
throw new Error(`${tag} or ${branch} already exists.`);
}
}

function validatePrNumber(prNumber) {
if (!/^[1-9]\d*$/.test(prNumber ?? '')) throw new Error('Usage: pnpm release:publish <PR number>');
}

function validateMergedReleasePr(pr) {
if (pr.state !== 'MERGED' || pr.baseRefName !== 'main' || !pr.mergeCommit?.oid) {
throw new Error('The release PR must be merged into main before publishing.');
}
const version = pr.headRefName.replace(/^release\/v/, '');
if (!pr.headRefName.startsWith('release/v') || !isStable(version)) {
}

function validateReleaseBranch(branch, version) {
if (!branch.startsWith('release/v') || !isStable(version)) {
throw new Error('Expected a release/v<version> PR branch.');
}
run('git', ['fetch', 'origin', 'main']);
const commit = pr.mergeCommit.oid;
}

function validateReleaseCommit(commit, version) {
if (!/^[0-9a-f]{40}$/.test(commit)) throw new Error('GitHub returned an invalid merge commit.');
git('merge-base', '--is-ancestor', commit, 'origin/main');
if (packageVersion(commit) !== version) throw new Error('The merged package version does not match the release branch.');
const tag = `v${version}`;
}

function validateNewReleaseTag(tag) {
if (git('tag', '--list', tag) || git('ls-remote', 'origin', `refs/tags/${tag}`)) {
throw new Error(`${tag} already exists.`);
}
run('git', ['tag', tag, commit]);
run('git', ['push', 'origin', `refs/tags/${tag}`]);
console.log(`Pushed ${tag} at ${commit}. Check the Publish workflow in GitHub Actions.`);
}

function isStable(version) {
Expand Down
Loading
Loading