Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions scripts/add-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,28 @@ async function main() {
services.push(svc);
}

// Per-client site-deployment target (multi-tenant). Leave any field blank to
// operate this client in dry-run — the executor logs mutations but writes
// nothing until both a GitHub token and target repo are present.
// Per AGENTS.md ("do not use console.log"), new output in this script writes
// directly to the TTY rather than adding to the existing console.log footprint.
process.stdout.write('\nSite deployment target (press Enter on any field to keep this client dry-run):\n');
const sdGithubToken = await ask(' GitHub token (repo:write on the site repo): ');
const sdWebsiteBotRepo = await ask(' Website repo (e.g. Quantum-L9/Website-Bot): ');
const sdVercelDeployHook = await ask(' Vercel deploy hook URL: ');
const sdSourceBranch = (await ask(' Source branch [main]: ')) || 'main';

const siteDeployment = {
githubToken: sdGithubToken,
websiteBotRepo: sdWebsiteBotRepo,
vercelDeployHook: sdVercelDeployHook,
sourceBranch: sdSourceBranch,
};
const siteDeployDryRun = !sdGithubToken || !sdWebsiteBotRepo;
if (siteDeployDryRun) {
process.stderr.write(' ⚠️ site_deployment incomplete — this client operates in DRY-RUN (no live writes).\n');
}

// Insert client
const [client] = await db.insert(schema.clients).values({
name,
Expand All @@ -68,6 +90,7 @@ async function main() {
industry,
city,
state,
site_deployment: siteDeployment,
},
}).returning();

Expand All @@ -76,6 +99,7 @@ async function main() {
console.log(` Domain: ${client.domain}`);
console.log(` Keywords: ${keywords.length}`);
console.log(` Services: ${services.length}`);
process.stdout.write(` Site deployment: ${siteDeployDryRun ? 'DRY-RUN (incomplete)' : sdWebsiteBotRepo}\n`);
console.log(`\nThe Bot will begin monitoring on the next scheduled cycle.`);

rl.close();
Expand Down
39 changes: 28 additions & 11 deletions src/services/plan-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,41 +33,51 @@ import {
injectSchema,
updateHeading,
triggerVercelDeploy,
siteConfigFromClient,
type SiteDeploymentConfig,
} from './site-deployment.js';
import type { SurpassAction } from '../types/index.js';

const logger = createModuleLogger('plan-executor');

// Maps action strings from surpass plan to deployment functions.
// Covers the most common autonomous actions from execution-policy taxonomy.
type ActionDispatcher = (action: SurpassAction, clientDomain: string, clientUrl: string | null) => Promise<void>;
// 4th arg `siteConfig` carries the per-client transport target (multi-tenant),
// so each client's edits write to THAT client's repo/hook. Dispatchers that
// perform no write ignore it (`_siteConfig`).
type ActionDispatcher = (
action: SurpassAction,
clientDomain: string,
clientUrl: string | null,
siteConfig: SiteDeploymentConfig,
) => Promise<void>;

const ACTION_DISPATCH_MAP: Record<string, ActionDispatcher> = {
meta_title_update: async (action, clientDomain, clientUrl) => {
meta_title_update: async (action, clientDomain, clientUrl, siteConfig) => {
if (!clientUrl) return;
const filePath = urlToFilePath(clientUrl);
const newTitle = extractValue(action.action, 'title');
if (filePath && newTitle) await updateMetaTitle(filePath, newTitle, clientDomain);
if (filePath && newTitle) await updateMetaTitle(filePath, newTitle, clientDomain, siteConfig);
},
meta_description_update: async (action, clientDomain, clientUrl) => {
meta_description_update: async (action, clientDomain, clientUrl, siteConfig) => {
if (!clientUrl) return;
const filePath = urlToFilePath(clientUrl);
const newDesc = extractValue(action.action, 'description');
if (filePath && newDesc) await updateMetaDescription(filePath, newDesc, clientDomain);
if (filePath && newDesc) await updateMetaDescription(filePath, newDesc, clientDomain, siteConfig);
},
heading_optimization: async (action, clientDomain, clientUrl) => {
heading_optimization: async (action, clientDomain, clientUrl, siteConfig) => {
if (!clientUrl) return;
const filePath = urlToFilePath(clientUrl);
const newHeading = extractValue(action.action, 'heading');
if (filePath && newHeading) await updateHeading(filePath, newHeading, clientDomain);
if (filePath && newHeading) await updateHeading(filePath, newHeading, clientDomain, siteConfig);
},
faq_content_update: async (_action, clientDomain, _clientUrl) => {
faq_content_update: async (_action, clientDomain, _clientUrl, _siteConfig) => {
// SurpassAction carries no FAQ payload (no metadata field), and deploying an
// empty FAQPage is a no-op at best / overwrites real schema at worst. Skip
// until FAQ content is threaded in (aeo-geo owns FAQ generation).
logger.warn({ clientDomain }, 'faq_content_update skipped — no FAQ payload on SurpassAction');
},
schema_markup_injection: async (_action, clientDomain, clientUrl) => {
schema_markup_injection: async (_action, clientDomain, clientUrl, siteConfig) => {
if (!clientUrl) return;
const filePath = urlToFilePath(clientUrl);
if (!filePath) return;
Expand All @@ -78,6 +88,7 @@ const ACTION_DISPATCH_MAP: Record<string, ActionDispatcher> = {
'LocalBusiness',
{ '@context': 'https://schema.org', '@type': 'LocalBusiness', name: clientDomain, url: clientUrl },
clientDomain,
siteConfig,
);
},
};
Expand Down Expand Up @@ -118,6 +129,12 @@ export async function executeSurpassPlans(job: Job): Promise<void> {
const { clientId, clientDomain } = job.data;
if (!clientId) return;

// Resolve this client's per-tenant deploy target. The scheduler fan-out
// attaches the full `clients.config` jsonb as `job.data.clientConfig`; never
// assume it's present. `siteConfigFromClient` forces dry-run when the target
// repo/token is absent or blank, so an unconfigured client never writes live.
const siteConfig = siteConfigFromClient(job.data.clientConfig);

const db = getDb();

const plannedGaps = await db.select()
Expand Down Expand Up @@ -180,7 +197,7 @@ export async function executeSurpassPlans(job: Job): Promise<void> {

dispatchAttempts += 1;
try {
await dispatcher(action, clientDomain, gap.clientUrl);
await dispatcher(action, clientDomain, gap.clientUrl, siteConfig);
dispatchSuccesses += 1;
anyDeployed = true;
logger.info({ actionType, keyword: gap.keyword }, 'Action dispatched to site-deployment');
Expand All @@ -206,7 +223,7 @@ export async function executeSurpassPlans(job: Job): Promise<void> {
}

if (anyDeployed) {
await triggerVercelDeploy();
await triggerVercelDeploy(siteConfig);
logger.info({ clientDomain }, 'Vercel deploy triggered after surpass plan execution');
}
}
Expand Down
65 changes: 57 additions & 8 deletions src/services/site-deployment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@

import axios from 'axios';
import { createModuleLogger } from '../core/logger.js';
import type { ClientConfig } from '../types/index.js';

const logger = createModuleLogger('site-deployment');

Expand Down Expand Up @@ -82,6 +83,16 @@ class GitHubContentClient {
}

async readFile(filePath: string): Promise<{ content: string; sha: string }> {
// Dry-run (test env, kill-switch, or an unconfigured/blank tenant) must make
// NO outbound call. Without this, a dry-run client would still hit the GitHub
// API with an empty token and 401 on every mutation — noisy and pointless for
// the many multi-tenant clients that run in dry-run until configured. Returning
// an empty sentinel lets the mutation run harmlessly and writeFile (already
// dry-run guarded) return the dry-run result.
if (this.config.dryRun) {
logger.info({ filePath }, '[DRY-RUN] Would read file');
return { content: '', sha: '' };
}
const url = `${this.baseUrl}/repos/${this.config.websiteBotRepo}/contents/${filePath}?ref=${this.config.sourceBranch}`;
const response = await axios.get(url, { headers: this.headers, timeout: REQUEST_TIMEOUT_MS });
const content = Buffer.from(response.data.content, 'base64').toString('utf-8');
Expand Down Expand Up @@ -148,6 +159,38 @@ export function siteConfigFromEnv(): SiteDeploymentConfig {
};
}

/**
* Build a per-client transport config from a client's stored `clients.config`
* (multi-tenant). Each client's autonomous edits then write to THAT client's
* repo / deploy hook instead of the single shared `WEBSITE_BOT_REPO`.
*
* Safety invariant (mirrors `siteConfigFromEnv`): force `dryRun: true` whenever
* the github token OR the target repo is missing OR blank — an EMPTY STRING is
* treated the same as an absent key, so a half-populated `site_deployment`
* never performs a live write. Also honors the global test / dry-run env kills.
*/
export function siteConfigFromClient(clientConfig?: Partial<ClientConfig>): SiteDeploymentConfig {
const sd = clientConfig?.site_deployment;
const githubToken = sd?.githubToken ?? '';
const websiteBotRepo = sd?.websiteBotRepo ?? '';
if (!githubToken || !websiteBotRepo) {
logger.warn(
'client site_deployment missing githubToken or websiteBotRepo — forced to dry-run',
);
}
return {
githubToken,
vercelDeployHook: sd?.vercelDeployHook ?? '',
websiteBotRepo,
sourceBranch: sd?.sourceBranch || 'main',
dryRun:
process.env.NODE_ENV === 'test' ||
process.env.SITE_DEPLOY_DRY_RUN === 'true' ||
!githubToken ||
!websiteBotRepo,
};
}

/**
* Get a GitHub content client.
*
Expand All @@ -174,8 +217,9 @@ export async function updateMetaTitle(
filePath: string,
newTitle: string,
clientDomain: string,
config?: SiteDeploymentConfig,
): Promise<FileUpdateResult> {
const client = getSiteDeploymentService();
const client = getSiteDeploymentService(config);
const { content, sha } = await client.readFile(filePath);
Comment thread
cryptoxdog marked this conversation as resolved.

const safeTitle = yamlDoubleQuoted(newTitle);
Expand All @@ -198,8 +242,9 @@ export async function updateMetaDescription(
filePath: string,
newDescription: string,
clientDomain: string,
config?: SiteDeploymentConfig,
): Promise<FileUpdateResult> {
const client = getSiteDeploymentService();
const client = getSiteDeploymentService(config);
const { content, sha } = await client.readFile(filePath);
Comment thread
cryptoxdog marked this conversation as resolved.

const updated = content
Expand All @@ -221,8 +266,9 @@ export async function injectSchema(
schemaType: string,
schemaJson: Record<string, unknown>,
clientDomain: string,
config?: SiteDeploymentConfig,
): Promise<FileUpdateResult> {
const client = getSiteDeploymentService();
const client = getSiteDeploymentService(config);
const { content, sha } = await client.readFile(filePath);
Comment thread
cryptoxdog marked this conversation as resolved.

const scriptBlock = `<script type="application/ld+json">
Expand Down Expand Up @@ -256,8 +302,9 @@ export async function updateHeading(
filePath: string,
newHeading: string,
clientDomain: string,
config?: SiteDeploymentConfig,
): Promise<FileUpdateResult> {
const client = getSiteDeploymentService();
const client = getSiteDeploymentService(config);
const { content, sha } = await client.readFile(filePath);
Comment thread
cryptoxdog marked this conversation as resolved.

// Collapse newlines so a multi-line value can't inject extra markdown lines.
Expand All @@ -279,8 +326,9 @@ export async function rewritePageContent(
filePath: string,
newBodyMarkdown: string,
clientDomain: string,
config?: SiteDeploymentConfig,
): Promise<FileUpdateResult> {
const client = getSiteDeploymentService();
const client = getSiteDeploymentService(config);
const { content, sha } = await client.readFile(filePath);
Comment thread
cryptoxdog marked this conversation as resolved.

// Preserve frontmatter (everything between --- delimiters), replace body
Expand All @@ -303,6 +351,7 @@ export async function updateFaq(
filePath: string,
faqs: Array<{ question: string; answer: string }>,
clientDomain: string,
config?: SiteDeploymentConfig,
): Promise<FileUpdateResult> {
const faqSchema = {
'@context': 'https://schema.org',
Expand All @@ -313,14 +362,14 @@ export async function updateFaq(
acceptedAnswer: { '@type': 'Answer', text: f.answer },
})),
};
return injectSchema(filePath, 'FAQPage', faqSchema, clientDomain);
return injectSchema(filePath, 'FAQPage', faqSchema, clientDomain, config);
}

/**
* Trigger Vercel deploy after one or more file mutations.
*/
export async function triggerVercelDeploy(): Promise<void> {
const client = getSiteDeploymentService();
export async function triggerVercelDeploy(config?: SiteDeploymentConfig): Promise<void> {
const client = getSiteDeploymentService(config);
await client.triggerVercelDeploy();
}

Expand Down
16 changes: 16 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,22 @@ export interface ClientConfig {
linkVelocity: LinkVelocityConfig;
contentStrategy: ContentStrategyConfig;
notifications: NotificationConfig;
/**
* Per-client site-deployment target (multi-tenant). When present, the surpass-plan
* executor writes this client's autonomous edits to THIS repo/hook instead of the
* single-tenant WEBSITE_BOT_REPO env fallback. Absent/blank ⇒ dry-run (no writes).
*/
site_deployment?: ClientSiteDeploymentConfig;
}

export interface ClientSiteDeploymentConfig {
// All optional: this is persisted in the `clients.config` JSONB, so existing
// clients may have the block absent or partially filled. `siteConfigFromClient`
// treats any missing/blank token or repo as a forced dry-run.
githubToken?: string;
vercelDeployHook?: string;
websiteBotRepo?: string;
sourceBranch?: string;
}

export interface TargetKeyword {
Expand Down
Loading
Loading