clawdie-ai/setup/cms.ts
2026-04-06 18:33:56 +00:00

927 lines
28 KiB
TypeScript

/**
* setup/cms.ts — Provision the CMS jail with Astro Starlight + nginx.
*
* Creates the jail if missing, installs packages, scaffolds the Starlight
* project from templates, builds the static site, deploys to /usr/local/www/clawdie,
* and starts nginx. Fully idempotent — safe to rerun.
*
* Strapi is NOT deployed by this step yet.
* The CMS jail serves a static Starlight site via nginx.
*/
import { execSync, spawnSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import {
AGENT_DOMAIN,
AGENT_INTERNAL_DOMAIN,
AGENT_NAME,
ASTRO_SITE_PATH,
CMS_JAIL_IP,
CMS_WEBROOT,
SUBNET_BASE,
} from '../src/config.js';
import { logger } from '../src/logger.js';
import { loadPackageList, mountPkgCacheInJail } from './packages.js';
import { getPlatform } from './platform.js';
import { emitStatus } from './status.js';
import { maybeEnableTailscaleInJail } from './tailscale.js';
import { readEnvFile } from '../src/env.js';
import { ensureScreenshotSecrets } from './secrets.js';
const LOG = 'logs/setup.log';
const CMS_USER = 'clawdie';
function bastille(...args: string[]): { ok: boolean; output: string } {
const result = spawnSync('bastille', args, {
encoding: 'utf-8',
env: process.env,
});
const output = [result.stdout || '', result.stderr || '']
.filter(Boolean)
.join('\n')
.trim();
return { ok: (result.status ?? 1) === 0, output };
}
function jailExists(name: string): boolean {
const { output } = bastille('list');
return output.split('\n').some((line) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('JID')) return false;
const cols = trimmed.split(/\s+/u);
return cols.length > 1 && cols[1] === name;
});
}
function detectFreeBSDRelease(): string {
const output = execSync('freebsd-version -u', { encoding: 'utf-8' }).trim();
const match = output.match(/^(\d+\.\d+-\w+)/);
return match?.[1] ?? output;
}
/** Resolve the jail root on the host filesystem. */
function jailRoot(jailName: string): string {
return `/usr/local/bastille/jails/${jailName}/root`;
}
// ── Starlight project template content ──────────────────────────────────
function starlightPackageJson(): string {
return JSON.stringify(
{
name: 'clawdie-docs',
private: true,
version: '0.0.1',
type: 'module',
scripts: {
dev: 'astro dev --host 0.0.0.0',
start: 'astro dev --host 0.0.0.0',
build: 'astro build',
preview: 'astro preview --host 0.0.0.0',
astro: 'astro',
},
dependencies: {
'@astrojs/starlight': '^0.37.3',
astro: '^5.16.11',
},
devDependencies: {
'@astrojs/check': '^0.9.6',
typescript: '^5.9.3',
tsx: '^4.19.2',
},
overrides: {
zod: '3.25.76',
},
},
null,
2,
);
}
function starlightConfig(siteUrl: string): string {
return `import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
const disableSitemap = {
name: '@astrojs/sitemap',
hooks: {
'astro:config:setup': () => {},
},
};
export default defineConfig({
site: '${siteUrl}',
output: 'static',
integrations: [
disableSitemap,
// Stub integration to prevent Starlight from loading @astrojs/sitemap.
starlight({
title: 'Clawdie Docs',
sidebar: [
{
label: 'Getting Started',
items: [
{ label: 'Overview', link: '/' },
{ label: 'Installation', link: 'install/' },
{ label: 'ISO Install', link: 'install/iso' },
{ label: 'Operations', link: 'operate/' },
],
},
{
label: 'Architecture',
items: [
{ label: 'Architecture', link: 'architecture/' },
{ label: 'Reference', link: 'reference/' },
{ label: 'Roadmap', link: 'roadmap/' },
],
},
],
}),
],
});
`;
}
function isManagedAstroConfig(content: string): boolean {
if (!content.includes("@astrojs/starlight")) return false;
if (!content.includes("Clawdie Docs")) return false;
if (!content.includes('disableSitemap')) return true;
return content.includes('Stub integration to prevent Starlight');
}
function starlightTsconfig(): string {
return JSON.stringify(
{
extends: 'astro/tsconfigs/strict',
compilerOptions: { baseUrl: '.' },
},
null,
2,
);
}
function starlightEnv(siteUrl: string): string {
return `SITE_URL=${siteUrl}
NODE_OPTIONS=--max-old-space-size=512
`;
}
function starlightContentConfig(): string {
return `import { defineCollection } from 'astro:content';
import { docsLoader } from '@astrojs/starlight/loaders';
import { docsSchema } from '@astrojs/starlight/schema';
export const collections = {
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
};
`;
}
function isManagedContentConfig(content: string): boolean {
return content.includes('defineCollection') && content.includes('docsLoader');
}
function starlightIndexPage(domain: string): string {
return `---
title: Overview
description: Operator documentation for ${domain}.
---
Welcome to the Clawdie operator documentation for ${domain}.
Use the sidebar to browse installation steps, system design, and runbooks.
`;
}
function titleFromFilename(filename: string): string {
const base = path.basename(filename, path.extname(filename));
const tokens = base.split(/[-_]+/u).filter(Boolean);
return tokens
.map((token) => {
if (!token) return token;
if (token.length <= 2 || token === token.toUpperCase()) return token;
return token[0].toUpperCase() + token.slice(1).toLowerCase();
})
.join(' ');
}
function ensureDocsFrontmatter(contentRoot: string): void {
const entries = fs.readdirSync(contentRoot, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(contentRoot, entry.name);
if (entry.isDirectory()) {
ensureDocsFrontmatter(entryPath);
continue;
}
if (!entry.isFile()) continue;
if (!entry.name.endsWith('.md') && !entry.name.endsWith('.mdx')) continue;
const content = fs.readFileSync(entryPath, 'utf-8');
if (!content.trimStart().startsWith('---')) {
if (/^#\s+\S/m.test(content)) continue;
}
const title = titleFromFilename(entry.name);
const next = upsertTitleFrontmatter(content, title);
if (next !== content) {
fs.writeFileSync(entryPath, next);
}
}
}
function upsertTitleFrontmatter(content: string, title: string): string {
if (content.trimStart().startsWith('---')) {
const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n?/);
if (match) {
const frontmatter = match[1];
const lines = frontmatter
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
const looksLikeYaml = lines.every(
(line) => line.startsWith('#') || /^[A-Za-z0-9_-]+\s*:/u.test(line),
);
if (looksLikeYaml) {
if (/(^|\n)title\s*:/i.test(frontmatter)) return content;
const updatedFrontmatter = `---\ntitle: ${title}\n${frontmatter}\n---\n`;
return updatedFrontmatter + content.slice(match[0].length);
}
}
}
return `---\ntitle: ${title}\n---\n\n${content}`;
}
function syncDocsToStarlightContent(astroRoot: string): void {
const sourceDir = path.join(process.cwd(), 'docs', 'public');
const targetDir = path.join(astroRoot, 'src', 'content', 'docs');
const filterFile = path.join(sourceDir, '.docignore');
if (!fs.existsSync(sourceDir)) {
logger.warn({ sourceDir }, 'Docs source missing; skipping Starlight sync');
return;
}
fs.rmSync(targetDir, { recursive: true, force: true });
fs.mkdirSync(targetDir, { recursive: true });
const args = [
'-a',
'--include=*/',
'--include=*.md',
'--include=*.mdx',
'--exclude=*',
];
if (fs.existsSync(filterFile)) {
args.push(`--exclude-from=${filterFile}`);
}
args.push(`${sourceDir}/`, `${targetDir}/`);
const result = spawnSync('rsync', args, { encoding: 'utf-8' });
if (result.error) {
logger.warn({ error: result.error.message }, 'Docs sync failed');
return;
}
if ((result.status ?? 1) !== 0) {
const output = [result.stdout || '', result.stderr || '']
.filter(Boolean)
.join('\n')
.trim();
logger.warn({ output }, 'Docs sync failed');
return;
}
ensureDocsFrontmatter(targetDir);
}
function ensureStarlightPackage(astroRoot: string): boolean {
const packagePath = path.join(astroRoot, 'package.json');
if (!fs.existsSync(packagePath)) {
fs.mkdirSync(path.dirname(packagePath), { recursive: true });
fs.writeFileSync(packagePath, starlightPackageJson());
logger.info({ file: 'package.json' }, 'Wrote Starlight package.json');
return true;
}
let pkg: Record<string, unknown>;
try {
pkg = JSON.parse(fs.readFileSync(packagePath, 'utf-8'));
} catch (error) {
logger.warn(
{ error: error instanceof Error ? error.message : String(error) },
'Invalid package.json; rewriting Starlight template',
);
fs.writeFileSync(packagePath, starlightPackageJson());
return true;
}
const currentNormalized = JSON.stringify(pkg, null, 2);
const updated = JSON.parse(JSON.stringify(pkg)) as {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
overrides?: Record<string, string>;
};
updated.dependencies = updated.dependencies || {};
updated.devDependencies = updated.devDependencies || {};
const ensureDep = (
group: Record<string, string>,
name: string,
version: string,
) => {
if (!group[name]) group[name] = version;
};
ensureDep(updated.dependencies, '@astrojs/starlight', '^0.37.3');
ensureDep(updated.dependencies, 'astro', '^5.16.11');
ensureDep(updated.devDependencies, '@astrojs/check', '^0.9.6');
ensureDep(updated.devDependencies, 'typescript', '^5.9.3');
ensureDep(updated.devDependencies, 'tsx', '^4.19.2');
updated.overrides = updated.overrides || {};
if (!updated.overrides.zod) {
updated.overrides.zod = '3.25.76';
}
const next = JSON.stringify(updated, null, 2);
if (next !== currentNormalized) {
fs.writeFileSync(packagePath, next);
logger.info({ file: 'package.json' }, 'Updated Starlight dependencies');
return true;
}
return false;
}
function isManagedNginxConf(content: string): boolean {
return (
content.includes('location /screenshots/') &&
content.includes('auth_basic_user_file /usr/local/etc/nginx/.htpasswd;') &&
content.includes('try_files $uri $uri/ /index.html;')
);
}
function isDefaultNginxConf(content: string): boolean {
return (
content.includes('server_name localhost;') &&
content.includes('root /usr/local/www/nginx;')
);
}
function nginxConf(): string {
return `worker_processes 1;
events {
worker_connections 64;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
server {
listen 80;
server_name _;
root ${CMS_WEBROOT};
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /screenshots/ {
auth_basic "Restricted";
auth_basic_user_file /usr/local/etc/nginx/.htpasswd;
autoindex on;
}
}
}
`;
}
function listZfsDatasets(): Array<{ name: string; mountpoint: string }> {
try {
const output = execSync('zfs list -H -o name,mountpoint -t filesystem', {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
});
return output
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [name, mountpoint] = line.split(/\t+/u);
return { name, mountpoint };
})
.filter((entry) => entry.name && entry.mountpoint);
} catch {
return [];
}
}
function ensureWebrootDataset(jailName: string, webroot: string): void {
const root = jailRoot(jailName);
const hostWebroot = path.resolve(root, webroot.replace(/^\/+/u, ''));
const datasets = listZfsDatasets();
if (datasets.length === 0) return;
const jailDataset = datasets.find((entry) => entry.mountpoint === root)?.name;
if (!jailDataset) {
logger.info('ZFS not detected for CMS jail root; using directory webroot');
return;
}
const datasetName = `${jailDataset}/webroot`;
const mounted = datasets.find((entry) => entry.mountpoint === hostWebroot);
if (mounted && mounted.name !== datasetName) {
logger.warn(
{ mountpoint: hostWebroot, dataset: mounted.name },
'CMS webroot already mounted from a different dataset',
);
return;
}
const exists = datasets.some((entry) => entry.name === datasetName);
try {
if (!exists) {
execSync('zfs create -p -o mountpoint=' + hostWebroot + ' ' + datasetName, {
stdio: ['ignore', 'ignore', 'ignore'],
});
logger.info({ dataset: datasetName }, 'Created CMS webroot dataset');
} else if (!mounted) {
execSync('zfs set mountpoint=' + hostWebroot + ' ' + datasetName, {
stdio: ['ignore', 'ignore', 'ignore'],
});
logger.info({ dataset: datasetName }, 'Mounted CMS webroot dataset');
}
} catch (error) {
logger.warn(
{ error: error instanceof Error ? error.message : String(error) },
'CMS webroot dataset setup failed; using directory webroot',
);
}
}
function ensureSitemapStub(astroRoot: string): void {
const nodeModules = path.join(astroRoot, 'node_modules');
if (!fs.existsSync(nodeModules)) return;
const stubRoot = path.join(nodeModules, '@astrojs', 'sitemap');
fs.rmSync(stubRoot, { recursive: true, force: true });
fs.mkdirSync(stubRoot, { recursive: true });
const packageJson = {
name: '@astrojs/sitemap',
version: '0.0.0-stub',
type: 'module',
exports: './index.js',
};
const indexJs = `export default function sitemap() {
return { name: '@astrojs/sitemap', hooks: {} };
}
`;
const indexDts = `declare const sitemap: () => { name: string; hooks: Record<string, unknown> };
export default sitemap;
`;
fs.writeFileSync(
path.join(stubRoot, 'package.json'),
JSON.stringify(packageJson, null, 2),
);
fs.writeFileSync(path.join(stubRoot, 'index.js'), indexJs);
fs.writeFileSync(path.join(stubRoot, 'index.d.ts'), indexDts);
logger.info('Stubbed @astrojs/sitemap to avoid zod conflicts');
}
export async function run(_args: string[]): Promise<void> {
const envOverrides = readEnvFile(['CMS_JAIL_NAME']);
const explicitJailName = (
process.env.CMS_JAIL_NAME || envOverrides.CMS_JAIL_NAME || ''
).trim();
const publicHost = AGENT_DOMAIN === 'home.arpa'
? AGENT_INTERNAL_DOMAIN
: AGENT_DOMAIN;
const domain = publicHost || AGENT_INTERNAL_DOMAIN;
const cmsHostname = `cms.${AGENT_INTERNAL_DOMAIN}`;
const siteUrl = `https://${domain}`;
// Feature gate — CMS_ENABLE defaults to YES (it's a core service)
const envFile = path.join(process.cwd(), '.env');
let cmsEnable = 'YES';
if (fs.existsSync(envFile)) {
const envContent = fs.readFileSync(envFile, 'utf-8');
cmsEnable =
envContent.match(/^CMS_ENABLE=(.+)$/m)?.[1]?.trim() || cmsEnable;
}
if (/^(NO|no|false|FALSE|0)$/u.test(cmsEnable)) {
emitStatus('SETUP_CMS', {
STATUS: 'skipped',
REASON: 'feature_disabled',
LOG,
});
logger.info('CMS jail skipped — CMS_ENABLE=NO');
return;
}
// Platform gate
if (getPlatform() !== 'freebsd') {
emitStatus('SETUP_CMS', {
STATUS: 'failed',
ERROR: 'unsupported_platform',
LOG,
});
process.exit(1);
}
const safeAgentName = AGENT_NAME.replace(/[-_]/g, '');
const defaultJailName = 'cms';
const preferredJailName = `${safeAgentName}cms`;
const legacyHyphenName = `${AGENT_NAME}-cms`;
let jailName = explicitJailName;
if (!jailName) {
if (jailExists(defaultJailName)) {
jailName = defaultJailName;
} else if (jailExists(preferredJailName)) {
jailName = preferredJailName;
} else if (jailExists(legacyHyphenName)) {
jailName = legacyHyphenName;
} else {
jailName = defaultJailName;
}
}
const runBastille = (args: string[]) => bastille(...args);
try {
const exists = jailExists(jailName);
// ── Create jail if missing ──────────────────────────────────────
if (!exists) {
const release = detectFreeBSDRelease();
const gateway = process.env.WARDEN_GATEWAY || `${SUBNET_BASE}.1`;
const bridge = process.env.WARDEN_BRIDGE || 'warden0';
logger.info({ jailName, ip: CMS_JAIL_IP, release }, 'Creating CMS jail');
const create = bastille(
'create',
'-T',
'-B',
'-g',
gateway,
jailName,
release,
`${CMS_JAIL_IP}/24`,
bridge,
);
if (!create.ok) {
throw new Error(`bastille create failed: ${create.output}`);
}
bastille('config', jailName, 'set', 'host.hostname', cmsHostname);
bastille('restart', jailName);
} else {
logger.info({ jailName }, 'CMS jail already exists, skipping creation');
}
ensureWebrootDataset(jailName, CMS_WEBROOT);
// ── Install packages ────────────────────────────────────────────
mountPkgCacheInJail(jailName);
const packages = loadPackageList('cms-jail.txt');
const pkg = bastille('pkg', jailName, 'install', '-y', ...packages);
if (!pkg.ok) {
logger.warn({ output: pkg.output }, 'Package install had warnings');
}
maybeEnableTailscaleInJail(runBastille, jailName, jailName);
// ── Create operator user ────────────────────────────────────────
const userCheck = bastille(
'cmd',
jailName,
'pw',
'usershow',
CMS_USER,
);
if (!userCheck.ok) {
bastille(
'cmd',
jailName,
'pw',
'useradd',
CMS_USER,
'-m',
'-s',
'/usr/local/bin/bash',
);
logger.info('Created clawdie user in CMS jail');
}
// ── Create directories ──────────────────────────────────────────
for (const dir of [ASTRO_SITE_PATH, CMS_WEBROOT]) {
bastille(
'cmd',
jailName,
'install',
'-d',
'-o',
CMS_USER,
'-g',
CMS_USER,
'-m',
'755',
dir,
);
}
// Starlight content dirs
for (const dir of [`${ASTRO_SITE_PATH}/src/content/docs`]) {
bastille(
'cmd',
jailName,
'install',
'-d',
'-o',
CMS_USER,
'-g',
CMS_USER,
'-m',
'755',
dir,
);
}
// Screenshots dir
bastille(
'cmd',
jailName,
'install',
'-d',
'-o',
CMS_USER,
'-g',
CMS_USER,
'-m',
'755',
`${CMS_WEBROOT}/screenshots`,
);
// ── Write Starlight project files ───────────────────────────────
const root = jailRoot(jailName);
const astroRoot = path.join(root, ASTRO_SITE_PATH);
const files: Array<{ relPath: string; content: string }> = [
{ relPath: 'astro.config.mjs', content: starlightConfig(siteUrl) },
{ relPath: 'tsconfig.json', content: starlightTsconfig() },
{ relPath: '.env', content: starlightEnv(siteUrl) },
{ relPath: 'src/content.config.ts', content: starlightContentConfig() },
];
for (const { relPath, content } of files) {
const dest = path.join(astroRoot, relPath);
// Only write if missing — don't clobber manual edits
if (!fs.existsSync(dest)) {
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, content);
logger.info({ file: relPath }, 'Wrote Starlight template');
}
}
const astroConfigPath = path.join(astroRoot, 'astro.config.mjs');
if (fs.existsSync(astroConfigPath)) {
const current = fs.readFileSync(astroConfigPath, 'utf-8');
const next = starlightConfig(siteUrl);
if (current !== next && isManagedAstroConfig(current)) {
fs.writeFileSync(astroConfigPath, next);
logger.info({ file: 'astro.config.mjs' }, 'Updated Starlight template');
}
}
const contentConfigPath = path.join(astroRoot, 'src/content.config.ts');
if (fs.existsSync(contentConfigPath)) {
const current = fs.readFileSync(contentConfigPath, 'utf-8');
const next = starlightContentConfig();
if (current !== next && isManagedContentConfig(current)) {
fs.writeFileSync(contentConfigPath, next);
logger.info(
{ file: 'src/content.config.ts' },
'Updated Starlight template',
);
}
}
const packageUpdated = ensureStarlightPackage(astroRoot);
syncDocsToStarlightContent(astroRoot);
const starlightIndexMdx = path.join(
astroRoot,
'src/content/docs/index.mdx',
);
const starlightIndexMd = path.join(
astroRoot,
'src/content/docs/index.md',
);
if (!fs.existsSync(starlightIndexMdx) && !fs.existsSync(starlightIndexMd)) {
fs.mkdirSync(path.dirname(starlightIndexMdx), { recursive: true });
fs.writeFileSync(starlightIndexMdx, starlightIndexPage(domain));
logger.info({ file: 'src/content/docs/index.mdx' }, 'Wrote index');
}
const starlight404 = path.join(astroRoot, 'src/content/docs/404.mdx');
if (!fs.existsSync(starlight404)) {
const content = `---
title: Page Not Found
---
# Page Not Found
The page you requested does not exist. Use the sidebar to find the right topic.
`;
fs.writeFileSync(starlight404, content);
logger.info({ file: 'src/content/docs/404.mdx' }, 'Wrote 404 page');
}
// Fix ownership
bastille(
'cmd',
jailName,
'chown',
'-R',
`${CMS_USER}:${CMS_USER}`,
ASTRO_SITE_PATH,
);
// Remove stale build output to avoid root-owned artifacts blocking builds.
bastille('cmd', jailName, 'rm', '-rf', `${ASTRO_SITE_PATH}/dist`);
// ── npm install ─────────────────────────────────────────────────
const hasNodeModules = bastille(
'cmd',
jailName,
'test',
'-d',
`${ASTRO_SITE_PATH}/node_modules`,
);
const hasTsx = bastille(
'cmd',
jailName,
'test',
'-d',
`${ASTRO_SITE_PATH}/node_modules/tsx`,
);
if (!hasNodeModules.ok || !hasTsx.ok || packageUpdated) {
logger.info('Running npm install in Starlight project');
const npmInstall = bastille(
'cmd',
jailName,
'su',
'-',
CMS_USER,
'-c',
`cd ${ASTRO_SITE_PATH} && npm install`,
);
if (!npmInstall.ok) {
throw new Error(`npm install failed: ${npmInstall.output}`);
}
}
ensureSitemapStub(astroRoot);
// ── Build Starlight site ────────────────────────────────────────
logger.info('Building Starlight site');
const build = bastille(
'cmd',
jailName,
'su',
'-',
CMS_USER,
'-c',
`cd ${ASTRO_SITE_PATH} && env NODE_OPTIONS="--import tsx" npm run build`,
);
if (!build.ok) {
throw new Error(`Starlight build failed: ${build.output}`);
}
// ── Deploy build to webroot ─────────────────────────────────────
const distDir = `${ASTRO_SITE_PATH}/dist/`;
const distCheck = bastille('cmd', jailName, 'test', '-d', distDir);
if (distCheck.ok) {
bastille(
'cmd',
jailName,
'sh',
'-c',
`rsync -a --delete --exclude "screenshots/" ${distDir} ${CMS_WEBROOT}/`,
);
logger.info('Deployed Starlight build to webroot');
bastille(
'cmd',
jailName,
'install',
'-d',
'-o',
CMS_USER,
'-g',
CMS_USER,
'-m',
'755',
`${CMS_WEBROOT}/screenshots`,
);
} else {
logger.warn('Starlight dist/ not found after build — webroot not updated');
}
// ── Configure nginx ─────────────────────────────────────────────
const nginxConfPath = path.join(
root,
'usr/local/etc/nginx/nginx.conf',
);
const desiredNginxConf = nginxConf();
if (!fs.existsSync(nginxConfPath)) {
fs.mkdirSync(path.dirname(nginxConfPath), { recursive: true });
fs.writeFileSync(nginxConfPath, desiredNginxConf);
logger.info('Wrote nginx.conf');
} else {
const current = fs.readFileSync(nginxConfPath, 'utf-8');
if (
current !== desiredNginxConf &&
(isManagedNginxConf(current) || isDefaultNginxConf(current))
) {
fs.writeFileSync(nginxConfPath, desiredNginxConf);
logger.info('Updated nginx.conf');
}
}
const screenshotSecrets = ensureScreenshotSecrets(process.cwd());
const htpasswdPath = path.join(root, 'usr/local/etc/nginx/.htpasswd');
const htpasswdDir = path.dirname(htpasswdPath);
fs.mkdirSync(htpasswdDir, { recursive: true });
const hash = spawnSync('openssl', ['passwd', '-apr1', screenshotSecrets.screenshotsPassword], {
encoding: 'utf-8',
}).stdout.trim();
if (hash) {
const line = `${screenshotSecrets.screenshotsUser}:${hash}\n`;
if (!fs.existsSync(htpasswdPath) || fs.readFileSync(htpasswdPath, 'utf-8') !== line) {
fs.writeFileSync(htpasswdPath, line, { mode: 0o640 });
logger.info('Wrote nginx screenshots credentials');
}
} else {
logger.warn('Failed to generate nginx screenshots credentials');
}
bastille('sysrc', jailName, 'nginx_enable=YES');
const start = bastille('service', jailName, 'nginx', 'restart');
if (!start.ok) {
logger.warn({ output: start.output }, 'nginx start had warnings');
}
// ── Enable on boot ──────────────────────────────────────────────
try {
execSync(`sysrc jail_list+="${jailName}"`, { stdio: 'ignore' });
} catch {
logger.warn('sysrc jail_list update failed — jail may not start on boot');
}
// ── Validate ────────────────────────────────────────────────────
await new Promise((resolve) => setTimeout(resolve, 1000));
const health = bastille(
'cmd',
jailName,
'fetch',
'-qo',
'-',
'http://127.0.0.1/',
);
if (!health.ok) {
logger.warn('CMS health check failed — nginx may still be starting');
} else {
logger.info('CMS nginx is responding');
}
emitStatus('SETUP_CMS', {
STATUS: 'success',
JAIL_NAME: jailName,
JAIL_IP: CMS_JAIL_IP,
CMS_WEBROOT,
ASTRO_SITE_PATH,
NGINX: start.ok ? 'running' : 'warning',
LOG,
});
logger.info(
{ jailName, ip: CMS_JAIL_IP, webroot: CMS_WEBROOT },
'CMS jail provisioned',
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
emitStatus('SETUP_CMS', {
STATUS: 'failed',
ERROR: message,
LOG,
});
throw error;
}
}