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)
1425 lines
42 KiB
TypeScript
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;
|
|
}
|
|
}
|