--- Build: pass | Tests: pass — 51 passed (3 files) --- Build: pass | Tests: pass — 2189 passed (648 files)
1131 lines
34 KiB
TypeScript
1131 lines
34 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,
|
|
ASTRO_SITE_PATH,
|
|
CMS_INTERNAL_DOMAIN,
|
|
CMS_JAIL_IP,
|
|
CMS_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 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 ensureCmsDocsBootstrapMounted(
|
|
jailName: string,
|
|
astroSitePath: string,
|
|
): void {
|
|
const hostBootstrap = path.resolve(
|
|
process.cwd(),
|
|
'bootstrap/cms/clawdie-site',
|
|
);
|
|
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',
|
|
},
|
|
);
|
|
}
|
|
|
|
const localPackageJson = path.join(astroRootOnHost, 'package.json');
|
|
const hostPackageJson = path.join(mountTargetOnHost, 'package.json');
|
|
if (!fs.existsSync(localPackageJson) && fs.existsSync(hostPackageJson)) {
|
|
fs.copyFileSync(hostPackageJson, localPackageJson);
|
|
}
|
|
|
|
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`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Starlight project template content ──────────────────────────────────
|
|
|
|
function starlightPackageJson(): string {
|
|
return JSON.stringify(
|
|
{
|
|
name: 'cms-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 } 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', 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/' },
|
|
],
|
|
},
|
|
];
|
|
|
|
export default defineConfig({
|
|
site,
|
|
outDir,
|
|
output: 'static',
|
|
integrations: [
|
|
disableSitemap,
|
|
// Stub integration to prevent Starlight from loading @astrojs/sitemap.
|
|
starlight({
|
|
title,
|
|
sidebar,
|
|
}),
|
|
],
|
|
});
|
|
`;
|
|
}
|
|
|
|
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 = AGENT_DOMAIN.trim();
|
|
const isPublicRootDomain =
|
|
!TENANT_ID.trim() &&
|
|
publicDomain.length > 0 &&
|
|
publicDomain !== 'home.arpa' &&
|
|
publicDomain !== AGENT_INTERNAL_DOMAIN;
|
|
const publicSiteHosts = isPublicRootDomain
|
|
? [publicDomain, `docs.${publicDomain}`]
|
|
: [];
|
|
const publicCmsHosts = isPublicRootDomain ? [`cms.${publicDomain}`] : [];
|
|
const siteHosts = surfaceInventory
|
|
.filter(
|
|
(surface) =>
|
|
surface.kind === 'tenant-home' || surface.kind === 'tenant-site',
|
|
)
|
|
.map((surface) => surface.host)
|
|
.concat(publicSiteHosts)
|
|
.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 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}
|
|
}
|
|
|
|
server {
|
|
listen 80;
|
|
server_name ${siteServerNames};
|
|
root ${CMS_WEBROOT};
|
|
index index.html;
|
|
|
|
location / {
|
|
try_files $uri $uri/ /index.html;
|
|
}
|
|
|
|
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 safeAgentName = TENANT_ID.replace(/[-_]/g, '');
|
|
const defaultJailName = 'cms';
|
|
const preferredJailName = `${safeAgentName}cms`;
|
|
const legacyHyphenName = `${TENANT_ID}-cms`;
|
|
const astroSitePathReal = jailPathNoHomeSymlink(ASTRO_SITE_PATH);
|
|
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',
|
|
// 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 ──────────────────────────────────────────
|
|
for (const dir of [astroSitePathReal, CMS_WEBROOT]) {
|
|
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,
|
|
);
|
|
}
|
|
}
|
|
// 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() },
|
|
];
|
|
|
|
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,
|
|
);
|
|
|
|
// Remove stale build output to avoid root-owned artifacts blocking builds.
|
|
bastille('cmd', jailName, 'rm', '-rf', `${astroSitePathReal}/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 (!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',
|
|
);
|
|
}
|
|
|
|
// ── 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,
|
|
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;
|
|
}
|
|
}
|