docs(journey): phase 7 — the installation path and administration
All checks were successful
PR checks / checks (pull_request) Successful in 9m25s

Twenty documentation pages: Getting started (7) and Administration (13), the
journey no existing document owns end to end because the repositories are
organised by component and an operator is not.

Four decisions of record, taken before anything was written (D34–D37, PLAN.md
§10 "How phase 7 built the documentation journey"):

- D34 one PR for all twenty pages.
- D35 the install page is SELF-CONTAINED: it prints a complete Compose file and
  a complete .env that an operator copies without visiting another repository.
  That is a copy of somebody else's file, so it is checked rather than trusted —
  scripts/checkQuickstart.mjs re-reads website main:docker-compose.yml and
  main:.env.example over the Gitea API and fails on any disagreement, in both
  directions: a value that drifts fails, and a service or variable that appears
  upstream fails until it is either included or recorded as deliberately omitted
  with a reason. Its first run found two stale entries.
- D36 every Administration screen was walked on a real deployment before it was
  described — the rig being the quickstart itself, against the published image,
  so one run proved the install page and produced the detail the admin pages
  needed.
- D37 a thirteenth Administration page, Content, so that every admin nav row has
  a home without organising the docs by the app's menu.

What the live deployment disproved, all three now documented:

- The documented Compose deploy does not boot. SECRET_ENC_KEY is required in
  production (utils/secretBox.js throws at require time) and is missing from
  website's ROOT .env.example — the file Compose reads. It is present in
  server/.env.example, which is why dev never hits it. The quickstart carries it,
  declared as an upstream omission so the check fails the day it is fixed.
- The installer points operators at a screen that no longer exists: it prints
  <site>/admin/shard, and INSTALL.md §5 repeats it, but since the module cutover
  the screen is /admin/uo/link. Both the binary and the guide are stale.
- The admin Restart button opens a window.confirm whose text is the honest
  warning that a deployment with no supervisor does not come back — which is why
  `restart: unless-stopped` is called out as load-bearing rather than left as
  boilerplate.

And the defect only a look found, three phases running: the .env block's prose
promised that every highlighted line must be changed, while `mark` given the
variable names highlighted the names alone and left the values unmarked. Every
check passed on a page that was wrong about its own highlighting.

verify green: 890 internal links, 52 branch links, 19 facts, 59 quickstart
checks, 0 astro-check errors.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-24 08:35:03 -05:00
parent 971fa9c032
commit f499f2b72b
27 changed files with 2260 additions and 19 deletions

216
scripts/checkQuickstart.mjs Normal file
View File

@@ -0,0 +1,216 @@
#!/usr/bin/env node
/**
* checkQuickstart.mjs — PLAN.md §12, added in phase 7 for D35.
*
* The org lead chose a self-contained quickstart: `/docs/getting-started/install-the-site/`
* prints a Compose file and an environment file the reader can copy without going to
* another repository first. That is the one place this site knowingly keeps a copy of
* somebody else's file, and §1 is a long argument about why copies rot.
*
* So the copy is checked rather than trusted. Every service, image, published port, mount
* and environment key in `src/data/quickstart.mjs` is re-read from `website`'s own
* `docker-compose.yml` and `.env.example` on `main`, over the Gitea API — never from a
* working tree, per §1's process rule — and any disagreement fails the build.
*
* It checks in BOTH directions, which is the property that keeps it honest:
*
* - every value the quickstart states must match upstream's;
* - every service and variable upstream has must be either included or listed as
* deliberately omitted, WITH a reason. A new variable in `.env.example` therefore turns
* this repo red until someone decides whether a first install needs it — the same
* intent as checkFacts.mjs and the Integration Kit's checkCoreApi.js;
* - and an entry in either omission list that upstream no longer has fails too, so the
* lists cannot rot into permanent exemptions.
*
* GITEA_TOKEN=<token> node scripts/checkQuickstart.mjs
*
* Anonymous raw fetches fail on this instance, so the token is required. A check that
* silently skips itself is worse than no check.
*/
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { parse as parseYaml } from 'yaml';
import {
compose,
services,
omittedServices,
env,
envOmitted,
notInUpstreamEnvExample,
} from '../src/data/quickstart.mjs';
const ROOT = fileURLToPath(new URL('..', import.meta.url));
const platform = JSON.parse(readFileSync(path.join(ROOT, 'src/data/platform.json'), 'utf8'));
const BASE = platform.gitea.base;
const ORG = platform.gitea.org;
const TOKEN = process.env.GITEA_TOKEN?.trim();
const failures = [];
const checked = [];
const ok = (what) => checked.push(what);
const fail = (what, detail) => failures.push({ what, detail });
/** Same raw-file accessor checkFacts.mjs uses, and for the same reason. */
async function raw(repo, filePath, ref) {
const url = `${BASE}/api/v1/repos/${ORG}/${repo}/raw/${filePath}?ref=${encodeURIComponent(ref)}`;
const res = await fetch(url, { headers: { Authorization: `token ${TOKEN}` } });
if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${url}`);
return res.text();
}
/**
* `KEY=value` lines from a dotenv file. Commented-out suggestions (`# MODULES=…`) are NOT
* keys: they are prose about a variable, and treating them as declared would make the
* omission list argue with documentation rather than with configuration.
*/
function envKeys(text) {
const out = new Map();
for (const line of text.split(/\r?\n/)) {
const m = line.match(/^([A-Z][A-Z0-9_]*)=(.*)$/);
if (m) out.set(m[1], m[2].replace(/\s+#.*$/, '').trim());
}
return out;
}
/** Published host:container port pairs, as written. */
const portsOf = (svc) => (svc.ports ?? []).map(String);
/** Container-side paths of every volume entry, which is what a reader's site depends on. */
const mountTargets = (svc) => (svc.volumes ?? []).map((v) => String(v).split(':')[1]);
async function run() {
if (!TOKEN) {
console.error('checkQuickstart: GITEA_TOKEN is not set. This check cannot run anonymously.');
process.exit(2);
}
const upstreamComposeText = await raw('website', 'docker-compose.yml', 'main');
const upstreamEnvText = await raw('website', '.env.example', 'main');
const upstream = parseYaml(upstreamComposeText);
const ours = parseYaml(compose);
if (!upstream?.services) throw new Error('website main:docker-compose.yml has no services block — the file shape changed.');
// ── 1. The services we ship ───────────────────────────────────────────────
for (const name of services) {
const mine = ours.services?.[name];
const theirs = upstream.services?.[name];
if (!mine) { fail(`service ${name}`, 'declared in quickstart.mjs but absent from its own compose text'); continue; }
if (!theirs) { fail(`service ${name}`, 'no longer exists in website main:docker-compose.yml'); continue; }
if (String(mine.image) !== String(theirs.image)) {
fail(`service ${name}: image`, `quickstart "${mine.image}" vs upstream "${theirs.image}"`);
} else ok(`service ${name}: image`);
const minePorts = portsOf(mine).join(', ');
const theirPorts = portsOf(theirs).join(', ');
if (minePorts !== theirPorts) {
fail(`service ${name}: ports`, `quickstart [${minePorts}] vs upstream [${theirPorts}]`);
} else ok(`service ${name}: ports`);
// Every mount we keep must land where upstream lands it. Upstream may have mounts we
// dropped (the schema bind, which needs a checkout); dropping one is safe, moving one
// is not.
for (const target of mountTargets(mine)) {
if (!mountTargets(theirs).includes(target)) {
fail(`service ${name}: mount ${target}`, 'upstream mounts nothing at that container path');
} else ok(`service ${name}: mount ${target}`);
}
for (const [key, value] of Object.entries(mine.environment ?? {})) {
const theirValue = theirs.environment?.[key];
if (theirValue === undefined) {
fail(`service ${name}: ${key}`, 'upstream no longer sets it in the compose file');
} else if (String(theirValue) !== String(value)) {
fail(`service ${name}: ${key}`, `quickstart "${value}" vs upstream "${theirValue}"`);
} else ok(`service ${name}: ${key}`);
}
}
// ── 2. The services we left out, and any that appeared ────────────────────
const upstreamServiceNames = Object.keys(upstream.services);
for (const [name, reason] of Object.entries(omittedServices)) {
if (!upstreamServiceNames.includes(name)) {
fail(`omitted service ${name}`, 'upstream no longer has this service — drop it from omittedServices');
} else if (!reason?.trim()) {
fail(`omitted service ${name}`, 'listed without a reason');
} else ok(`omitted service ${name}`);
}
for (const name of upstreamServiceNames) {
if (!services.includes(name) && !(name in omittedServices)) {
fail(`service ${name}`, 'is new in website main:docker-compose.yml — include it in the quickstart or record why not');
}
}
// ── 3. The environment file ───────────────────────────────────────────────
const theirEnv = envKeys(upstreamEnvText);
const mineEnv = new Map(env.map((e) => [e.key, e]));
for (const entry of env) {
const theirValue = theirEnv.get(entry.key);
const excused = notInUpstreamEnvExample[entry.key];
if (theirValue === undefined) {
if (excused) {
ok(`env ${entry.key} (absent upstream, declared: ${excused})`);
} else {
fail(`env ${entry.key}`, 'not in website main:.env.example — either it is gone, or it needs a reason in notInUpstreamEnvExample');
}
continue;
}
if (excused) {
fail(
`env ${entry.key}`,
'is now in website main:.env.example — remove it from notInUpstreamEnvExample, and re-read the prose that describes it as missing',
);
continue;
}
// A value an operator is told to replace is a placeholder on both sides; comparing two
// placeholders would only ever assert that two people picked the same filler words.
if (!entry.fill && theirValue !== String(entry.value)) {
fail(`env ${entry.key}`, `quickstart "${entry.value}" vs upstream "${theirValue}"`);
} else ok(`env ${entry.key}`);
}
for (const [key, reason] of Object.entries(envOmitted)) {
if (!theirEnv.has(key)) {
fail(`omitted env ${key}`, 'upstream .env.example no longer sets it — drop it from envOmitted');
} else if (!reason?.trim()) {
fail(`omitted env ${key}`, 'listed without a reason');
} else ok(`omitted env ${key}`);
}
for (const key of theirEnv.keys()) {
if (!mineEnv.has(key) && !(key in envOmitted)) {
fail(`env ${key}`, 'is new in website main:.env.example — add it to the quickstart or record why a first install does not need it');
}
}
// ── Report ────────────────────────────────────────────────────────────────
if (failures.length === 0) {
console.log(`checkQuickstart: ${checked.length} checks passed against website main.`);
return;
}
console.error(`checkQuickstart: ${failures.length} disagreement(s) with website main:\n`);
for (const f of failures) console.error(`${f.what}\n ${f.detail}`);
console.error(
'\nThe quickstart on /docs/getting-started/install-the-site/ is a copy of website\'s own\n'
+ 'deployment files (D35). Either update src/data/quickstart.mjs to match, or record the\n'
+ 'difference with a reason. Do not "fix" the check.',
);
process.exit(1);
}
run().catch((err) => {
console.error(`checkQuickstart: ${err.message}`);
process.exit(1);
});