clawdie-ai/setup/publish-tenant-site.ts
Operator & Codex e3ad322d3b Rename Astro docs project to clawdie-docs (Sam & Claude)
Make the docs renderer name match its purpose, add CMS_DOCS_SITE_PATH with ASTRO_SITE_PATH compatibility, and update docs publishing paths.

---
Build: pass | Tests: pass — 2372 passed (704 files)
2026-05-10 19:49:39 +02:00

310 lines
8.1 KiB
TypeScript

import { SERVICE_NAME } from '../src/platform-identity.js';
import fs from 'fs';
import path from 'path';
import { CMS_DOCS_SITE_PATH, CMS_WEBROOT } from '../src/config.js';
import { logger } from '../src/logger.js';
import {
resolveTenantSiteDocs,
resolveTenantSiteContentSource,
TENANT_SITE_SNAPSHOT_ROOT,
} from '../src/tenant-site-content.js';
import {
buildTenantSiteBuildEnv,
buildTenantSitePublishPlan,
hostVisibleJailPath,
writeTenantSitePublishStatusFiles,
type TenantSitePublishStatus,
} from '../src/tenant-site-publish.js';
import { loadTenantRegistry, type TenantRecord } from '../src/tenant-registry.js';
import { bastille, jailExists } from './bastille-helpers.js';
import { readEnvFile } from '../src/env.js';
interface ParsedArgs {
tenantId: string;
siteId: string;
skipBuild: boolean;
}
function parseArgs(args: string[]): ParsedArgs {
let tenantId = '';
let siteId = '';
let skipBuild = false;
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === '--tenant') {
tenantId = (args[i + 1] || '').trim();
i += 1;
continue;
}
if (arg === '--site') {
siteId = (args[i + 1] || '').trim();
i += 1;
continue;
}
if (arg === '--skip-build') {
skipBuild = true;
continue;
}
}
if (!tenantId || !siteId) {
throw new Error(
'Usage: tsx setup/publish-tenant-site.ts --tenant <tenant> --site <site> [--skip-build]',
);
}
return { tenantId, siteId, skipBuild };
}
function resolveCmsJailName(): string {
const envOverrides = readEnvFile(['CMS_JAIL_NAME']);
return (
process.env.CMS_JAIL_NAME ||
envOverrides.CMS_JAIL_NAME ||
'cms'
).trim();
}
function syncHostSiteSlices(jailName: string): void {
const hostMount = `${CMS_DOCS_SITE_PATH}-host`;
const syncScripts = bastille(
'cmd',
jailName,
'sh',
'-c',
`test -d ${hostMount}/scripts && rsync -a ${hostMount}/scripts/ ${CMS_DOCS_SITE_PATH}/scripts/`,
);
if (!syncScripts.ok) {
throw new Error(`Failed to sync host scripts into local Astro project: ${syncScripts.output}`);
}
const syncAstroSource = bastille(
'cmd',
jailName,
'sh',
'-c',
`test -f ${hostMount}/src/astro/astro.config.mjs && install -d -o ${SERVICE_NAME} -g ${SERVICE_NAME} -m 755 ${CMS_DOCS_SITE_PATH}/src/astro && cp ${hostMount}/src/astro/astro.config.mjs ${CMS_DOCS_SITE_PATH}/src/astro/astro.config.mjs`,
);
if (!syncAstroSource.ok) {
throw new Error(`Failed to sync host Astro config source into local project: ${syncAstroSource.output}`);
}
}
function buildAstroSite(
jailName: string,
buildEnv: {
ASTRO_OUT_DIR: string;
ASTRO_SITE_TITLE: string;
ASTRO_SITE_URL: string;
ASTRO_SITE_VARIANT: string;
},
): void {
const ownership = bastille(
'cmd',
jailName,
'chown',
'-R',
`${SERVICE_NAME}:${SERVICE_NAME}`,
CMS_DOCS_SITE_PATH,
);
if (!ownership.ok) {
throw new Error(`Failed to normalize Astro project ownership in ${jailName}: ${ownership.output}`);
}
syncHostSiteSlices(jailName);
const build = bastille(
'cmd',
jailName,
'su',
'-',
SERVICE_NAME,
'-c',
`cd ${CMS_DOCS_SITE_PATH} && env ASTRO_OUT_DIR=${JSON.stringify(buildEnv.ASTRO_OUT_DIR)} ASTRO_SITE_TITLE=${JSON.stringify(buildEnv.ASTRO_SITE_TITLE)} ASTRO_SITE_URL=${JSON.stringify(buildEnv.ASTRO_SITE_URL)} ASTRO_SITE_VARIANT=${JSON.stringify(buildEnv.ASTRO_SITE_VARIANT)} npm run build`,
);
if (!build.ok) {
throw new Error(`Astro build failed in ${jailName}: ${build.output}`);
}
}
function applyTenantSiteDocs(
jailName: string,
tenant: TenantRecord,
siteId: string,
): () => void {
const site = tenant.sites.find((entry) => entry.id === siteId);
if (!site) {
throw new Error(`Unknown tenant site in registry: ${tenant.id}/${siteId}`);
}
const docsRoot = hostVisibleJailPath(
jailName,
path.join(CMS_DOCS_SITE_PATH, 'src/content/docs'),
);
const generatedDocs = resolveTenantSiteDocs(
tenant,
site,
TENANT_SITE_SNAPSHOT_ROOT,
);
const previous = new Map<string, string | null>();
for (const page of generatedDocs) {
const pagePath = path.join(docsRoot, page.relPath);
previous.set(pagePath, fs.existsSync(pagePath) ? fs.readFileSync(pagePath, 'utf-8') : null);
fs.mkdirSync(path.dirname(pagePath), { recursive: true });
fs.writeFileSync(pagePath, page.content, 'utf-8');
}
return () => {
for (const [pagePath, priorContent] of previous.entries()) {
if (priorContent === null) {
if (fs.existsSync(pagePath)) {
fs.rmSync(pagePath);
}
continue;
}
fs.mkdirSync(path.dirname(pagePath), { recursive: true });
fs.writeFileSync(pagePath, priorContent, 'utf-8');
}
};
}
function ensureTargetDir(
jailName: string,
targetDir: string,
): void {
const mkdir = bastille(
'cmd',
jailName,
'install',
'-d',
'-o',
SERVICE_NAME,
'-g',
SERVICE_NAME,
'-m',
'755',
targetDir,
);
if (!mkdir.ok) {
throw new Error(`Failed to create tenant site target dir ${targetDir}: ${mkdir.output}`);
}
}
function syncBuiltSite(
jailName: string,
sourceDistDir: string,
targetDir: string,
): void {
const sync = bastille(
'cmd',
jailName,
'sh',
'-c',
`rsync -a --delete ${sourceDistDir}/ ${targetDir}/`,
);
if (!sync.ok) {
throw new Error(`Failed to sync tenant site output into ${targetDir}: ${sync.output}`);
}
}
function writePublishManifest(
jailName: string,
status: TenantSitePublishStatus,
): void {
writeTenantSitePublishStatusFiles(
path.join(
hostVisibleJailPath(jailName, status.targetDir),
'.clawdie-publish.json',
),
status,
);
}
export async function run(rawArgs: string[]): Promise<void> {
const args = parseArgs(rawArgs);
const jailName = resolveCmsJailName();
if (!jailExists(jailName)) {
throw new Error(`CMS jail does not exist: ${jailName}`);
}
const registry = loadTenantRegistry();
const plan = buildTenantSitePublishPlan(registry, {
tenantId: args.tenantId,
siteId: args.siteId,
astroSitePath: CMS_DOCS_SITE_PATH,
webroot: CMS_WEBROOT,
});
const buildEnv = buildTenantSiteBuildEnv(plan);
if (!args.skipBuild) {
const tenant = registry.tenants[args.tenantId];
if (!tenant) {
throw new Error(`Unknown tenant in registry: ${args.tenantId}`);
}
const restoreDocs = applyTenantSiteDocs(jailName, tenant, args.siteId);
try {
buildAstroSite(jailName, buildEnv);
} finally {
restoreDocs();
}
}
const hostDistDir = hostVisibleJailPath(jailName, plan.sourceDistDir);
if (!fs.existsSync(hostDistDir)) {
throw new Error(`Built Astro dist/ not found on host path: ${hostDistDir}`);
}
ensureTargetDir(jailName, plan.targetDir);
syncBuiltSite(jailName, plan.sourceDistDir, plan.targetDir);
const tenant = registry.tenants[args.tenantId];
const site = tenant?.sites.find((entry) => entry.id === args.siteId);
if (!tenant || !site) {
throw new Error(`Unknown tenant site in registry: ${args.tenantId}/${args.siteId}`);
}
writePublishManifest(jailName, {
tenantId: plan.tenantId,
siteId: plan.siteId,
siteFqdn: plan.siteFqdn,
contentSource: resolveTenantSiteContentSource(
tenant,
site,
TENANT_SITE_SNAPSHOT_ROOT,
),
publishedAt: new Date().toISOString(),
targetDir: plan.targetDir,
targetIndex: plan.targetIndex,
sourceDistDir: plan.sourceDistDir,
result: 'published',
});
logger.info(
{
tenantId: plan.tenantId,
siteId: plan.siteId,
siteFqdn: plan.siteFqdn,
sourceDistDir: plan.sourceDistDir,
targetDir: plan.targetDir,
targetIndex: plan.targetIndex,
cmsJail: jailName,
},
'Published tenant site build into CMS webroot',
);
}
const entrypoint = process.argv[1]
? path.resolve(process.argv[1])
: null;
const isDirectRun =
!!entrypoint && import.meta.url === new URL(`file://${entrypoint}`).href;
if (isDirectRun) {
run(process.argv.slice(2)).catch((error) => {
const message = error instanceof Error ? error.message : String(error);
console.error(message);
process.exit(1);
});
}