927 lines
28 KiB
TypeScript
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;
|
|
}
|
|
}
|