clawdie-ai/setup/cms.ts
Operator & Codex f1dc7ea6df Drop stale jail and agent migration paths (Codex)
Remove completed controlplane agent-id migration, simplify jail-name resolution to current canonical names, and drop SUDO_UID ownership fallback from service setup.

---
Build: pass | Tests: pass — 2370 passed (704 files)
2026-05-10 21:30:17 +02:00

1425 lines
42 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/<agent>/,
* 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 { SERVICE_NAME } from '../src/platform-identity.js';
import { execSync, spawnSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import {
AGENT_DOMAIN,
AGENT_INTERNAL_DOMAIN,
CMS_DOCS_SITE_PATH,
CMS_INTERNAL_DOMAIN,
CMS_JAIL_IP,
CMS_WEBROOT,
PLATFORM_LANDING_SITE_PATH,
PLATFORM_LANDING_WEBROOT,
SUBNET_BASE,
TENANT_ID,
} from '../src/config.js';
import { logger } from '../src/logger.js';
import { buildSurfaceInventory } from '../src/surface-inventory.js';
import { loadTenantRegistry } from '../src/tenant-registry.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 {
bastille,
jailExists,
detectFreeBSDRelease,
jailRoot,
} from './bastille-helpers.js';
const LOG = 'logs/setup.log';
const CMS_USER = SERVICE_NAME;
function publicRootDomain(): string {
const publicDomain = AGENT_DOMAIN.trim();
if (
TENANT_ID.trim() ||
publicDomain.length === 0 ||
publicDomain === 'home.arpa' ||
publicDomain === AGENT_INTERNAL_DOMAIN
) {
return '';
}
return publicDomain;
}
function jailPathNoHomeSymlink(value: string): string {
if (value.startsWith('/usr/home/')) return value;
if (value.startsWith('/home/'))
return value.replace(/^\/home\//u, '/usr/home/');
return value;
}
function ensureSymlink(linkPath: string, targetPath: string): void {
try {
const stat = fs.lstatSync(linkPath);
if (stat.isSymbolicLink()) {
const current = fs.readlinkSync(linkPath);
if (current === targetPath) return;
}
const backup = `${linkPath}.bak`;
if (fs.existsSync(backup)) {
fs.rmSync(backup, { recursive: true, force: true });
}
fs.renameSync(linkPath, backup);
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code !== 'ENOENT') throw err;
}
fs.symlinkSync(targetPath, linkPath);
}
function pathInJailRoot(root: string, jailAbsolutePath: string): string {
return path.join(root, jailAbsolutePath.replace(/^\//u, ''));
}
function ensureCmsBootstrapMounted(
jailName: string,
astroSitePath: string,
bootstrapRelativePath: string,
): void {
const hostBootstrap = path.resolve(process.cwd(), bootstrapRelativePath);
if (!fs.existsSync(hostBootstrap)) {
throw new Error(`Missing Astro bootstrap at ${hostBootstrap}`);
}
const fstabPath = `/usr/local/bastille/jails/${jailName}/fstab`;
const astroSiteReal = jailPathNoHomeSymlink(astroSitePath);
const mountTarget = `${astroSiteReal}-host`;
const root = jailRoot(jailName);
const mountTargetOnHost = pathInJailRoot(root, mountTarget);
fs.mkdirSync(mountTargetOnHost, { recursive: true });
const astroRootOnHost = pathInJailRoot(root, astroSiteReal);
fs.mkdirSync(astroRootOnHost, { recursive: true });
const fstabContent = fs.existsSync(fstabPath)
? fs.readFileSync(fstabPath, 'utf-8')
: '';
if (
!fstabContent.includes(` ${mountTarget} `) &&
!fstabContent.includes(`${hostBootstrap} `)
) {
execSync(
`bastille mount ${jailName} ${hostBootstrap} ${mountTarget} nullfs rw 0 0`,
{
stdio: 'ignore',
},
);
}
for (const rootFile of [
'package.json',
'package-lock.json',
'tsconfig.json',
]) {
const localPath = path.join(astroRootOnHost, rootFile);
const hostPath = path.join(mountTargetOnHost, rootFile);
if (fs.existsSync(hostPath)) {
const host = fs.readFileSync(hostPath, 'utf-8');
const current = fs.existsSync(localPath)
? fs.readFileSync(localPath, 'utf-8')
: '';
if (current !== host) {
fs.writeFileSync(localPath, host);
}
}
}
const localAstroConfig = path.join(astroRootOnHost, 'astro.config.mjs');
const hostAstroConfig = path.join(mountTargetOnHost, 'astro.config.mjs');
const stub = `import config from './src/astro/astro.config.mjs';\n\nexport default config;\n`;
try {
const stat = fs.lstatSync(localAstroConfig);
if (stat.isSymbolicLink()) {
fs.rmSync(localAstroConfig, { force: true });
fs.writeFileSync(localAstroConfig, stub);
}
if (fs.existsSync(hostAstroConfig)) {
const current = fs.readFileSync(localAstroConfig, 'utf-8');
const host = fs.readFileSync(hostAstroConfig, 'utf-8');
if (current !== host) {
fs.writeFileSync(localAstroConfig, host);
}
}
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code !== 'ENOENT') throw err;
if (fs.existsSync(hostAstroConfig)) {
fs.writeFileSync(
localAstroConfig,
fs.readFileSync(hostAstroConfig, 'utf-8'),
);
} else {
fs.writeFileSync(localAstroConfig, stub);
}
}
for (const dir of ['src', 'scripts']) {
const destPath = path.join(astroRootOnHost, dir);
const srcPath = path.join(mountTargetOnHost, dir);
if (!fs.existsSync(srcPath)) continue;
// Remove stale symlink if present — symlinks break Vite path resolution
try {
if (fs.lstatSync(destPath).isSymbolicLink()) {
fs.rmSync(destPath, { force: true });
}
} catch (err) {
const e = err as NodeJS.ErrnoException;
if (e.code !== 'ENOENT') throw e;
}
fs.mkdirSync(destPath, { recursive: true });
const result = spawnSync(
'rsync',
['-a', '--delete', `${srcPath}/`, `${destPath}/`],
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },
);
if ((result.status ?? 1) !== 0) {
logger.warn(
{ dir, error: result.stderr },
`Failed to sync ${dir} from bootstrap`,
);
}
}
}
function ensureCmsDocsBootstrapMounted(
jailName: string,
astroSitePath: string,
): void {
ensureCmsBootstrapMounted(
jailName,
astroSitePath,
'bootstrap/cms/clawdie-docs',
);
}
function ensureCmsLandingBootstrapMounted(
jailName: string,
landingSitePath: string,
): void {
ensureCmsBootstrapMounted(
jailName,
landingSitePath,
'bootstrap/cms/clawdie-si',
);
}
// ── 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',
},
},
null,
2,
);
}
function starlightConfig(siteUrl: string): string {
return `import { defineConfig, passthroughImageService } from 'astro/config';
import starlight from '@astrojs/starlight';
const disableSitemap = {
name: '@astrojs/sitemap',
hooks: {
'astro:config:setup': () => {},
},
};
const site = process.env.ASTRO_SITE_URL || '${siteUrl}';
const title = process.env.ASTRO_SITE_TITLE || 'Clawdie Docs';
const outDir = process.env.ASTRO_OUT_DIR || './dist';
const siteVariant = process.env.ASTRO_SITE_VARIANT || 'docs';
const sidebar =
siteVariant === 'tenant-site'
? [
{
label: 'Site',
items: [
{ label: 'Overview', link: '/' },
{ label: 'About', link: 'about/' },
{ label: 'Status', link: 'status/' },
],
},
]
: [
{
label: 'Getting Started',
items: [{ label: 'Overview', link: '/' }],
},
{ label: 'Installation', autogenerate: { directory: 'install' } },
{ label: 'Operations', autogenerate: { directory: 'operate' } },
{ label: 'Architecture', autogenerate: { directory: 'architecture' } },
{ label: 'Reference', autogenerate: { directory: 'reference' } },
{ label: 'Roadmap', autogenerate: { directory: 'roadmap' } },
{ label: 'Localization', autogenerate: { directory: 'localization' } },
];
export default defineConfig({
site,
outDir,
output: 'static',
image: {
service: passthroughImageService(),
},
integrations: [
disableSitemap,
// Stub integration to prevent Starlight from loading @astrojs/sitemap.
starlight({
title,
logo: {
src: '/src/astro/clawdie-mark.svg',
alt: 'Clawdie',
},
social: [
{ icon: 'external', label: 'Clawdie.si', href: 'https://clawdie.si/' },
{
icon: 'codeberg',
label: 'Codeberg',
href: 'https://codeberg.org/Clawdie/Clawdie-AI',
},
],
customCss: ['/src/styles/custom.css'],
defaultLocale: 'root',
locales: {
root: {
label: 'English',
lang: 'en',
},
sl: {
label: 'Slovenščina',
lang: 'sl',
},
},
sidebar,
}),
],
});
`;
}
function starlightLogoSvg(): string {
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Clawdie mark">
<rect width="64" height="64" rx="14" fill="#0d1117"/>
<path d="M32 11 53 50H11L32 11Z" fill="none" stroke="#00b4d8" stroke-width="4" stroke-linejoin="round"/>
<circle cx="32" cy="32" r="4" fill="#00b4d8"/>
</svg>
`;
}
function starlightBrandCss(): string {
return `:root {
--clawdie-bg: #101722;
--clawdie-panel: #161b22;
--clawdie-rule: #21262d;
--clawdie-accent: #00b4d8;
--clawdie-fg-dim: #c9d1d9;
--clawdie-grey: #8b949e;
--sl-color-accent-low: #07313f;
--sl-color-accent: var(--clawdie-accent);
--sl-color-accent-high: #b8f3ff;
--sl-color-white: #f0f6fc;
--sl-color-gray-2: var(--clawdie-fg-dim);
--sl-color-gray-3: var(--clawdie-grey);
--sl-color-gray-5: var(--clawdie-rule);
--sl-color-gray-6: var(--clawdie-panel);
--sl-color-black: var(--clawdie-bg);
--sl-font-mono: 'DM Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
}
:root[data-theme='light'] {
--sl-color-accent-low: #d7f7ff;
--sl-color-accent: #007c9c;
--sl-color-accent-high: #063747;
--sl-color-white: #0d1117;
--sl-color-gray-1: #1c2333;
--sl-color-gray-2: #30363d;
--sl-color-gray-3: #57606a;
--sl-color-gray-4: #d7dee8;
--sl-color-gray-5: #e5ebf2;
--sl-color-gray-6: #ffffff;
--sl-color-black: #f8fafc;
}
:root[data-theme='dark'] body {
background-color: var(--clawdie-bg);
}
:root[data-theme='light'] body {
background-color: #f8fafc;
}
.header,
.sidebar-pane,
.right-sidebar,
.mobile-preferences,
.pagination-links a,
.card {
border-color: color-mix(in srgb, var(--sl-color-gray-5), var(--clawdie-accent) 10%);
}
:root[data-theme='dark'] .header,
:root[data-theme='dark'] .sidebar-pane,
:root[data-theme='dark'] .right-sidebar,
:root[data-theme='dark'] .mobile-preferences {
background-color: color-mix(in srgb, var(--clawdie-panel), transparent 8%);
backdrop-filter: blur(10px);
}
.site-title img {
border-radius: 0.45rem;
box-shadow: 0 0 0 1px var(--clawdie-rule), 0 0 18px color-mix(in srgb, var(--clawdie-accent), transparent 76%);
}
.sl-markdown-content h2 {
border-bottom-color: color-mix(in srgb, var(--sl-color-gray-5), var(--clawdie-accent) 18%);
}
.sl-markdown-content a:not(:where(.not-content *)) {
text-decoration-color: color-mix(in srgb, var(--sl-color-accent), transparent 45%);
text-underline-offset: 0.18em;
}
.sl-markdown-content blockquote {
border-inline-start-color: var(--sl-color-accent);
background: color-mix(in srgb, var(--sl-color-accent-low), transparent 56%);
padding-block: 0.35rem;
}
`;
}
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>;
};
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');
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('listen 80 default_server;') &&
content.includes('location /api/') &&
content.includes('return 404;') &&
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;')
);
}
export function nginxConf(opts: { adminUiEnabled: boolean }): string {
const registry = loadTenantRegistry();
const surfaceInventory = buildSurfaceInventory(registry);
const publicDomain = publicRootDomain();
const isPublicRootDomain = publicDomain.length > 0;
const publicDocsHosts = isPublicRootDomain ? [`docs.${publicDomain}`] : [];
const publicLandingHosts = isPublicRootDomain
? [publicDomain, `www.${publicDomain}`]
: [];
const publicCmsHosts = isPublicRootDomain ? [`cms.${publicDomain}`] : [];
const siteHosts = surfaceInventory
.filter(
(surface) =>
surface.kind === 'tenant-home' || surface.kind === 'tenant-site',
)
.map((surface) => surface.host)
.concat(publicDocsHosts)
.sort();
const siteServerNames =
siteHosts.length > 0 ? siteHosts.join(' ') : AGENT_INTERNAL_DOMAIN;
const cmsHost =
surfaceInventory.find((surface) => surface.kind === 'cms-admin')?.host ||
CMS_INTERNAL_DOMAIN;
const cmsServerNames = [cmsHost, ...publicCmsHosts].join(' ');
const landingServerBlock =
publicLandingHosts.length > 0
? `
server {
listen 80;
server_name ${publicLandingHosts.join(' ')};
root ${PLATFORM_LANDING_WEBROOT};
index index.html;
location = / {
return 301 https://${publicDomain}/en/;
}
location /en/ {
try_files $uri $uri/ /en/index.html =404;
}
location /sl/ {
try_files $uri $uri/ /sl/index.html =404;
}
location / {
try_files $uri $uri/ =404;
}
}
`
: '';
const adminBlock = opts.adminUiEnabled
? ` location /admin/ {
proxy_pass http://127.0.0.1:1337/admin/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/ {
proxy_pass http://127.0.0.1:1337/api/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
`
: ` location /admin/ {
return 404;
}
location /api/ {
return 404;
}
`;
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 default_server;
server_name _;
location / {
return 404;
}
}
server {
listen 80;
server_name ${cmsServerNames};
location / {
return 404;
}
${adminBlock}
}
${landingServerBlock}
server {
listen 80;
server_name ${siteServerNames};
root ${CMS_WEBROOT};
index index.html;
location / {
try_files $uri $uri/ =404;
}
location /screenshots/ {
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', 'CMS_SITE_SOURCE']);
const explicitJailName = (
process.env.CMS_JAIL_NAME ||
envOverrides.CMS_JAIL_NAME ||
''
).trim();
const cmsSiteSource = (
process.env.CMS_SITE_SOURCE ||
envOverrides.CMS_SITE_SOURCE ||
'bootstrap'
)
.trim()
.toLowerCase();
const useBootstrapSite = cmsSiteSource !== 'scaffold';
const domain = AGENT_INTERNAL_DOMAIN;
const cmsHostname = CMS_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';
let cmsAdminUi = 'NO';
if (fs.existsSync(envFile)) {
const envContent = fs.readFileSync(envFile, 'utf-8');
cmsEnable =
envContent.match(/^CMS_ENABLE=(.+)$/m)?.[1]?.trim() || cmsEnable;
cmsAdminUi =
envContent.match(/^CMS_ADMIN_UI=(.+)$/m)?.[1]?.trim() || cmsAdminUi;
}
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 defaultJailName = 'cms';
const astroSitePathReal = jailPathNoHomeSymlink(CMS_DOCS_SITE_PATH);
const landingSitePathReal = jailPathNoHomeSymlink(PLATFORM_LANDING_SITE_PATH);
const landingPublicDomain = publicRootDomain();
const landingEnabled = landingPublicDomain.length > 0;
let jailName = explicitJailName;
if (!jailName) {
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',
// thin jail (no -T): shared service jails stay thin by default.
'-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 ${CMS_USER} user in CMS jail`);
}
// ── Create directories ──────────────────────────────────────────
const managedDirs = [astroSitePathReal, CMS_WEBROOT];
if (landingEnabled) {
managedDirs.push(landingSitePathReal, PLATFORM_LANDING_WEBROOT);
}
for (const dir of managedDirs) {
bastille(
'cmd',
jailName,
'install',
'-d',
'-o',
CMS_USER,
'-g',
CMS_USER,
'-m',
'755',
dir,
);
}
if (useBootstrapSite) {
ensureCmsDocsBootstrapMounted(jailName, astroSitePathReal);
} else {
// Starlight content dirs
for (const dir of [`${astroSitePathReal}/src/content/docs`]) {
bastille(
'cmd',
jailName,
'install',
'-d',
'-o',
CMS_USER,
'-g',
CMS_USER,
'-m',
'755',
dir,
);
}
}
if (landingEnabled) {
ensureCmsLandingBootstrapMounted(jailName, landingSitePathReal);
}
// Screenshots dir
bastille(
'cmd',
jailName,
'install',
'-d',
'-o',
CMS_USER,
'-g',
CMS_USER,
'-m',
'755',
`${CMS_WEBROOT}/screenshots`,
);
const root = jailRoot(jailName);
const astroRoot = pathInJailRoot(root, astroSitePathReal);
let packageUpdated = false;
if (useBootstrapSite) {
const envPath = path.join(astroRoot, '.env');
if (!fs.existsSync(envPath)) {
fs.writeFileSync(
envPath,
[
`SITE_URL=${siteUrl}`,
'',
'# Strapi export (optional)',
'# STRAPI_BASE_URL=http://127.0.0.1:1337',
'# STRAPI_API_TOKEN=... (keep in jail-local .env, never commit)',
'',
].join('\n'),
);
fs.chmodSync(envPath, 0o600);
logger.info({ file: '.env' }, 'Wrote Astro project .env template');
}
} else {
// ── Write Starlight project files ───────────────────────────────
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() },
{ relPath: 'src/astro/clawdie-mark.svg', content: starlightLogoSvg() },
{ relPath: 'src/styles/custom.css', content: starlightBrandCss() },
];
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',
);
}
}
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}`,
astroSitePathReal,
);
if (landingEnabled) {
bastille(
'cmd',
jailName,
'chown',
'-R',
`${CMS_USER}:${CMS_USER}`,
landingSitePathReal,
);
}
// Remove stale build output to avoid root-owned artifacts blocking builds.
bastille('cmd', jailName, 'rm', '-rf', `${astroSitePathReal}/dist`);
if (landingEnabled) {
bastille('cmd', jailName, 'rm', '-rf', `${landingSitePathReal}/dist`);
}
// ── npm install ─────────────────────────────────────────────────
const hasNodeModules = bastille(
'cmd',
jailName,
'test',
'-d',
`${astroSitePathReal}/node_modules`,
);
const hasTsx = bastille(
'cmd',
jailName,
'test',
'-d',
`${astroSitePathReal}/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 ${astroSitePathReal} && npm install`,
);
if (!npmInstall.ok) {
throw new Error(`npm install failed: ${npmInstall.output}`);
}
}
if (landingEnabled) {
const landingHasNodeModules = bastille(
'cmd',
jailName,
'test',
'-d',
`${landingSitePathReal}/node_modules`,
);
const landingHasAstro = bastille(
'cmd',
jailName,
'test',
'-d',
`${landingSitePathReal}/node_modules/astro`,
);
const landingHasSitemap = bastille(
'cmd',
jailName,
'test',
'-d',
`${landingSitePathReal}/node_modules/@astrojs/sitemap`,
);
if (
!landingHasNodeModules.ok ||
!landingHasAstro.ok ||
!landingHasSitemap.ok
) {
logger.info('Running npm install in landing project');
const landingNpmInstall = bastille(
'cmd',
jailName,
'su',
'-',
CMS_USER,
'-c',
`cd ${landingSitePathReal} && npm install`,
);
if (!landingNpmInstall.ok) {
throw new Error(
`Landing npm install failed: ${landingNpmInstall.output}`,
);
}
}
}
if (!useBootstrapSite) {
ensureSitemapStub(astroRoot);
}
// ── Build Starlight site ────────────────────────────────────────
logger.info('Building Starlight site');
const buildCommand = useBootstrapSite
? `cd ${astroSitePathReal} && npm run build`
: `cd ${astroSitePathReal} && env NODE_OPTIONS="--import tsx" npm run build`;
const build = bastille(
'cmd',
jailName,
'su',
'-',
CMS_USER,
'-c',
buildCommand,
);
if (!build.ok) {
throw new Error(`Starlight build failed: ${build.output}`);
}
// ── Deploy build to webroot ─────────────────────────────────────
const distDir = `${astroSitePathReal}/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',
);
}
if (landingEnabled) {
logger.info('Building clawdie.si landing site');
const landingBuild = bastille(
'cmd',
jailName,
'su',
'-',
CMS_USER,
'-c',
`cd ${landingSitePathReal} && env ASTRO_SITE_URL="https://${landingPublicDomain}" ASTRO_OUT_DIR="./dist" npm run build`,
);
if (!landingBuild.ok) {
throw new Error(`Landing build failed: ${landingBuild.output}`);
}
const landingDistDir = `${landingSitePathReal}/dist/`;
const landingDistCheck = bastille(
'cmd',
jailName,
'test',
'-d',
landingDistDir,
);
if (landingDistCheck.ok) {
bastille(
'cmd',
jailName,
'sh',
'-c',
`rsync -a --delete ${landingDistDir} ${PLATFORM_LANDING_WEBROOT}/`,
);
logger.info('Deployed clawdie.si landing build to webroot');
} else {
logger.warn(
'Landing dist/ not found after build — landing webroot not updated',
);
}
}
// ── Configure nginx ─────────────────────────────────────────────
const nginxConfPath = path.join(root, 'usr/local/etc/nginx/nginx.conf');
const adminUiEnabled = /^(YES|yes|true|TRUE|1)$/u.test(cmsAdminUi);
const desiredNginxConf = nginxConf({ adminUiEnabled });
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');
}
}
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,
CMS_DOCS_SITE_PATH,
PLATFORM_LANDING_SITE_PATH: landingEnabled
? PLATFORM_LANDING_SITE_PATH
: '',
PLATFORM_LANDING_WEBROOT: landingEnabled ? PLATFORM_LANDING_WEBROOT : '',
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;
}
}