Add multi-account OAuth, Obsidian integration, product assets, and test tooling

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Garfield
2026-04-29 09:52:53 -04:00
parent 166f5d55a6
commit e3a272c332
67 changed files with 6204 additions and 94 deletions

9
product/site/Dockerfile Normal file
View File

@@ -0,0 +1,9 @@
FROM nginx:1.27-alpine
COPY product/site/nginx-site.conf /etc/nginx/conf.d/default.conf
COPY product/site/index.html /usr/share/nginx/html/index.html
COPY product/site/styles.css /usr/share/nginx/html/styles.css
COPY product/site/script.js /usr/share/nginx/html/script.js
COPY product/site/squaremcp_launch.gif /usr/share/nginx/html/squaremcp_launch.gif
COPY product/site/squaremcp_launch_poster.png /usr/share/nginx/html/squaremcp_launch_poster.png
COPY product/site/privacy.html /usr/share/nginx/html/privacy.html

View File

@@ -0,0 +1,64 @@
# SquareMCP Site Verification
Use this command after deployment changes to verify the live marketing site:
```bash
npm run test:product-site:verify
```
Or use the full deploy wrapper:
```bash
npm run deploy:product-site:verify
```
What it covers:
1. `smoke`
- verifies page JS behavior in isolation
- checks submit flow wiring, copy behavior, and hero poster swap logic
2. `e2e-desktop`
- loads the live site in Chromium
- checks navigation anchors
- waits for the hero animation to swap to the poster
- submits the live pilot request form
- verifies success UI and form reset
- confirms the request ID is written to the vault
3. `e2e-mobile`
- repeats the live submit flow in a mobile viewport
- checks for horizontal overflow and clipped key layout regions
- confirms the request ID is written to the vault
- compares the captured mobile screenshot against the stored baseline
Visual diff baselines:
- `product/site/baselines/desktop.png`
- `product/site/baselines/mobile.png`
Diff artifacts:
- `/tmp/squaremcp-e2e-desktop-diff.png`
- `/tmp/squaremcp-e2e-mobile-diff.png`
Artifacts:
- desktop screenshot: `/tmp/squaremcp-e2e-desktop.png`
- mobile screenshot: `/tmp/squaremcp-e2e-mobile.png`
Vault write targets:
- `SquareMCP/Pilot Requests.md`
- `Daily Notes/<today>.md`
Automated browser test submissions are tagged for cleanup:
- `#squaremcp-e2e`
- `#squaremcp-test-cleanup`
To remove those entries from the vault logs:
```bash
npm run test:product-site:cleanup
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 662 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 576 KiB

View File

@@ -0,0 +1,47 @@
import fs from "fs";
import path from "path";
const vaultRoot = process.env.OBSIDIAN_VAULT_PATH || "/home/garfield/obsidian/vaults";
const cleanupTag = "#squaremcp-test-cleanup";
const pilotLogPath = path.join(vaultRoot, "SquareMCP", "Pilot Requests.md");
const dailyNotePath = path.join(
vaultRoot,
"Daily Notes",
`${new Intl.DateTimeFormat("en-CA", {
timeZone: "America/New_York",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date())}.md`
);
function removeTaggedBlocks(content) {
const normalized = content.replace(/\r\n/g, "\n");
const blocks = normalized.split(/\n{2,}/);
const kept = blocks.filter((block) => !block.includes(cleanupTag));
return {
changed: kept.length !== blocks.length,
content: kept.join("\n\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n",
removedCount: blocks.length - kept.length,
};
}
function cleanFile(filePath) {
if (!fs.existsSync(filePath)) {
return { changed: false, removedCount: 0 };
}
const original = fs.readFileSync(filePath, "utf8");
const result = removeTaggedBlocks(original);
if (result.changed) {
fs.writeFileSync(filePath, result.content, "utf8");
}
return result;
}
const pilotLogResult = cleanFile(pilotLogPath);
const dailyLogResult = cleanFile(dailyNotePath);
console.log("squaremcp cleanup complete");
console.log(`pilot_log_removed: ${pilotLogResult.removedCount}`);
console.log(`daily_log_removed: ${dailyLogResult.removedCount}`);

View File

@@ -0,0 +1,53 @@
import fs from "fs";
import { PNG } from "pngjs";
import pixelmatch from "pixelmatch";
const actualPath = process.argv[2];
const baselinePath = process.argv[3];
const diffPath = process.argv[4] || "/tmp/squaremcp-visual-diff.png";
const threshold = Number(process.argv[5] || "0.02");
const maxDiffRatio = Number(process.argv[6] || "0.01");
if (!actualPath || !baselinePath) {
console.error("usage: node compare-screenshot.mjs <actual> <baseline> [diff] [threshold] [maxDiffRatio]");
process.exit(1);
}
if (!fs.existsSync(actualPath) || !fs.existsSync(baselinePath)) {
console.error("actual or baseline screenshot is missing");
process.exit(1);
}
const actual = PNG.sync.read(fs.readFileSync(actualPath));
const baseline = PNG.sync.read(fs.readFileSync(baselinePath));
if (actual.width !== baseline.width || actual.height !== baseline.height) {
console.error(
`dimension mismatch: actual ${actual.width}x${actual.height}, baseline ${baseline.width}x${baseline.height}`
);
process.exit(1);
}
const diff = new PNG({ width: actual.width, height: actual.height });
const mismatchedPixels = pixelmatch(
actual.data,
baseline.data,
diff.data,
actual.width,
actual.height,
{ threshold }
);
const diffRatio = mismatchedPixels / (actual.width * actual.height);
fs.writeFileSync(diffPath, PNG.sync.write(diff));
if (diffRatio > maxDiffRatio) {
console.error(
`visual diff exceeded threshold: mismatched=${mismatchedPixels} ratio=${diffRatio.toFixed(6)} diff=${diffPath}`
);
process.exit(1);
}
console.log(
`visual diff PASS: mismatched=${mismatchedPixels} ratio=${diffRatio.toFixed(6)} diff=${diffPath}`
);

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
echo "==> build hermes"
docker build -t localhost:32000/hermes-mcp:latest -f Dockerfile .
echo "==> build site"
docker build --no-cache -t localhost:32000/squaremcp-site:latest -f product/site/Dockerfile .
echo "==> push hermes"
docker push localhost:32000/hermes-mcp:latest
echo "==> push site"
docker push localhost:32000/squaremcp-site:latest
echo "==> apply ingress"
microk8s kubectl apply -f product/site/squaremcp-k8s-ingress.yaml
echo "==> restart hermes"
microk8s kubectl rollout restart deployment/hermes-mcp -n fetcherpay
echo "==> restart site"
microk8s kubectl rollout restart deployment/squaremcp-site -n fetcherpay
echo "==> wait hermes"
microk8s kubectl rollout status deployment/hermes-mcp -n fetcherpay
echo "==> wait site"
microk8s kubectl rollout status deployment/squaremcp-site -n fetcherpay
echo "==> verify"
npm run test:product-site:verify

257
product/site/e2e-test.mjs Normal file
View File

@@ -0,0 +1,257 @@
import fs from "fs";
import path from "path";
import { spawnSync } from "child_process";
import { chromium } from "playwright";
const baseUrl = process.env.SQUAREMCP_BASE_URL || "https://squaremcp.com";
const profile = process.env.SQUAREMCP_E2E_PROFILE || "desktop";
const screenshotPath =
process.env.SQUAREMCP_E2E_SCREENSHOT || `/tmp/squaremcp-e2e-${profile}.png`;
const baselinePath =
process.env.SQUAREMCP_E2E_BASELINE ||
path.join(process.cwd(), "product/site/baselines", `${profile}.png`);
const diffPath =
process.env.SQUAREMCP_E2E_DIFF || `/tmp/squaremcp-e2e-${profile}-diff.png`;
const vaultRoot = process.env.OBSIDIAN_VAULT_PATH || "/home/garfield/obsidian/vaults";
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function uniqueEmail() {
const stamp = Date.now();
return `squaremcp-e2e-${profile}-${stamp}@example.com`;
}
function getProfileSettings() {
if (profile === "mobile") {
return {
viewport: { width: 390, height: 1180 },
isMobile: true,
hasTouch: true,
};
}
return {
viewport: { width: 1440, height: 1400 },
isMobile: false,
hasTouch: false,
};
}
async function runMobileLayoutChecks(page) {
const layout = await page.evaluate(() => {
const selectors = [
".topbar-row",
".topbar-actions",
"h1",
".lede",
".hero-points",
".actions",
".hero-panel",
"#pilot-form",
];
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const scrollWidth = document.documentElement.scrollWidth;
const issues = [];
for (const selector of selectors) {
const element = document.querySelector(selector);
if (!element) {
issues.push(`missing element: ${selector}`);
continue;
}
const rect = element.getBoundingClientRect();
if (rect.left < -1 || rect.right > viewportWidth + 1) {
issues.push(`horizontal overflow: ${selector}`);
}
if (rect.width <= 0 || rect.height <= 0) {
issues.push(`collapsed element: ${selector}`);
}
}
const actionButtons = Array.from(document.querySelectorAll(".actions .button"));
if (actionButtons.length >= 2) {
const first = actionButtons[0].getBoundingClientRect();
const second = actionButtons[1].getBoundingClientRect();
const overlap =
first.left < second.right &&
first.right > second.left &&
first.top < second.bottom &&
first.bottom > second.top;
if (overlap) {
issues.push("action buttons overlap");
}
}
return {
scrollWidth,
viewportWidth,
viewportHeight,
issues,
};
});
assert(
layout.scrollWidth <= layout.viewportWidth + 1,
`mobile horizontal overflow detected (${layout.scrollWidth} > ${layout.viewportWidth})`
);
assert(layout.issues.length === 0, `mobile layout issues: ${layout.issues.join(", ")}`);
}
function getEasternDateString() {
return new Intl.DateTimeFormat("en-CA", {
timeZone: "America/New_York",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date());
}
async function waitForVaultWrite(requestId) {
const pilotLogPath = path.join(vaultRoot, "SquareMCP", "Pilot Requests.md");
const dailyNotePath = path.join(vaultRoot, "Daily Notes", `${getEasternDateString()}.md`);
const timeoutMs = 10000;
const intervalMs = 250;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const pilotLog = fs.existsSync(pilotLogPath) ? fs.readFileSync(pilotLogPath, "utf8") : "";
const dailyLog = fs.existsSync(dailyNotePath) ? fs.readFileSync(dailyNotePath, "utf8") : "";
if (pilotLog.includes(requestId) && dailyLog.includes(requestId)) {
return { pilotLogPath, dailyNotePath };
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error(`vault write not observed for request id ${requestId}`);
}
async function run() {
const browser = await chromium.launch({
headless: true,
args: [
"--host-resolver-rules=MAP squaremcp.com 104.190.60.129,MAP www.squaremcp.com 104.190.60.129",
],
});
const context = await browser.newContext(getProfileSettings());
const page = await context.newPage();
try {
const response = await page.goto(baseUrl, { waitUntil: "networkidle" });
assert(response && response.ok(), "page did not load successfully");
assert((await page.title()) === "SquareMCP", "page title mismatch");
await page.click('a.topbar-link[href="#pricing"]');
await page.waitForFunction(() => window.location.hash === "#pricing");
await page.click('a.topbar-link[href="#pilot-form"]');
await page.waitForFunction(() => window.location.hash === "#pilot-form");
if (profile === "mobile") {
await runMobileLayoutChecks(page);
}
const heroImage = page.locator("#heroAnimation");
await heroImage.waitFor();
const initialSrc = await heroImage.getAttribute("src");
assert(
initialSrc && initialSrc.includes("squaremcp_launch.gif"),
"hero image did not start with animated asset"
);
const playMs = Number((await heroImage.getAttribute("data-play-ms")) || "0");
assert(playMs > 0, "hero animation play duration missing");
await page.waitForTimeout(playMs + 750);
const finalSrc = await heroImage.getAttribute("src");
assert(
finalSrc && finalSrc.includes("squaremcp_launch_poster.png"),
"hero image did not swap to poster"
);
await page.screenshot({ path: screenshotPath, fullPage: true });
if (fs.existsSync(baselinePath)) {
const compare = spawnSync(
process.execPath,
["product/site/compare-screenshot.mjs", screenshotPath, baselinePath, diffPath, "0.02", "0.015"],
{ stdio: "inherit" }
);
assert(compare.status === 0, `visual diff failed for ${profile}`);
}
const email = uniqueEmail();
await page.fill('input[name="name"]', `SquareMCP ${profile.toUpperCase()} E2E`);
await page.fill('input[name="email"]', email);
await page.fill('input[name="company"]', `SquareMCP ${profile} Browser Test`);
await page.fill('input[name="role"]', "QA");
await page.evaluate(() => {
const tagField = document.querySelector('input[name="submission_tag"]');
if (tagField) {
tagField.value = "#squaremcp-e2e #squaremcp-test-cleanup";
}
});
await page.selectOption('select[name="use_case"]', { label: "Internal support copilot" });
await page.selectOption('select[name="timeline"]', { label: "Within 2 weeks" });
await page.fill('textarea[name="systems"]', "Postgres, internal REST APIs");
await page.fill('textarea[name="requirements"]', "Audit logs and SSO");
const submitResponsePromise = page.waitForResponse(
(resp) => resp.url().includes("/api/pilot-request") && resp.request().method() === "POST"
);
await page.click('button[type="submit"]');
const submitResponse = await submitResponsePromise;
assert(submitResponse.ok(), `pilot submit failed with status ${submitResponse.status()}`);
const submitJson = await submitResponse.json();
assert(submitJson.ok === true, "submit response missing ok=true");
assert(
typeof submitJson.request_id === "string" && submitJson.request_id.length > 0,
"submit response missing request id"
);
const outputText = await page.locator("#pilotOutput").textContent();
assert(
outputText && outputText.includes("Saved to SquareMCP intake successfully."),
"success message missing"
);
assert(outputText.includes(submitJson.request_id), "success message missing request id");
const nameValue = await page.locator('input[name="name"]').inputValue();
assert(nameValue === "", "form did not reset after submit");
const vaultTargets = await waitForVaultWrite(submitJson.request_id);
const pilotLog = fs.readFileSync(vaultTargets.pilotLogPath, "utf8");
const dailyLog = fs.readFileSync(vaultTargets.dailyNotePath, "utf8");
assert(
pilotLog.includes("#squaremcp-e2e #squaremcp-test-cleanup"),
"pilot log missing cleanup tag"
);
assert(
dailyLog.includes("#squaremcp-e2e #squaremcp-test-cleanup"),
"daily log missing cleanup tag"
);
console.log(`squaremcp product site e2e test (${profile}): PASS`);
console.log(`request_id: ${submitJson.request_id}`);
console.log(`email: ${email}`);
console.log(`screenshot: ${screenshotPath}`);
console.log(`baseline: ${baselinePath}`);
console.log(`diff: ${diffPath}`);
console.log(`pilot_log: ${vaultTargets.pilotLogPath}`);
console.log(`daily_log: ${vaultTargets.dailyNotePath}`);
} finally {
await context.close();
await browser.close();
}
}
run().catch((error) => {
console.error(`squaremcp product site e2e test (${profile}): FAIL: ${error.message}`);
process.exit(1);
});

303
product/site/index.html Normal file
View File

@@ -0,0 +1,303 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SquareMCP</title>
<meta
name="description"
content="SquareMCP is a managed MCP gateway for internal tools with authentication, permissions, audit logs, and observability."
/>
<link rel="stylesheet" href="./styles.css?v=20260424b" />
</head>
<body>
<nav class="topbar">
<div class="wrap topbar-row">
<a class="brand" href="/">
<span class="brand-mark">S</span>
<span class="brand-text">SquareMCP</span>
</a>
<div class="topbar-actions">
<a class="topbar-link" href="#pricing">Pricing</a>
<a class="topbar-link" href="#pilot-form">Pilot intake</a>
<a class="button secondary" href="mailto:info@squaremcp.com">Contact</a>
</div>
</div>
</nav>
<header class="hero">
<div class="wrap hero-grid">
<div class="hero-copy">
<div class="eyebrow">Managed MCP infrastructure</div>
<h1>SquareMCP for secure internal tool access</h1>
<p class="lede">
Let AI agents work with your internal systems without bypassing operating controls.
SquareMCP gives teams a managed MCP layer for authentication, permissions, audit
logs, and production visibility from day one.
</p>
<div class="hero-points">
<span>Fast pilot deployment</span>
<span>Tool-level controls</span>
<span>Audit-ready access</span>
</div>
<div class="hero-contact">
Contact:
<a href="mailto:info@squaremcp.com">info@squaremcp.com</a>
</div>
<div class="actions">
<a class="button primary" href="#pilot-form">Book a pilot</a>
<a class="button secondary" href="#pricing">View pricing</a>
</div>
</div>
<section class="hero-panel" aria-labelledby="pilot-preview-title">
<div class="hero-media">
<img
id="heroAnimation"
src="./squaremcp_launch.gif"
data-animated-src="./squaremcp_launch.gif"
data-poster-src="./squaremcp_launch_poster.png"
data-play-ms="9600"
alt="SquareMCP launch storyboard preview"
width="728"
height="410"
/>
</div>
<div class="panel-topline">Typical first deployment</div>
<h2 id="pilot-preview-title">Internal support copilot with safe system access</h2>
<ul class="signal-list">
<li>
<strong>Connectors:</strong>
REST APIs, Postgres, internal operations tools
</li>
<li>
<strong>Controls:</strong>
allowlists, deny lists, revocation, access boundaries
</li>
<li>
<strong>Visibility:</strong>
request logs, usage traces, deployment-level audit history
</li>
</ul>
<div class="metric-row">
<div class="metric">
<span class="metric-value">1 day</span>
<span class="metric-label">first safe endpoint</span>
</div>
<div class="metric">
<span class="metric-value">$5k+</span>
<span class="metric-label">pilot setup range</span>
</div>
</div>
</section>
</div>
</header>
<main>
<section class="band">
<div class="wrap section-head">
<div>
<div class="kicker">Why teams buy</div>
<h2>Put agent access behind a layer your team can operate</h2>
</div>
<p class="section-copy">
The buyer does not want another framework to assemble. They want a managed path to
exposing internal tools to agents without opening production systems up blindly.
</p>
</div>
<div class="wrap feature-grid">
<article class="feature">
<h3>Authentication</h3>
<p>Give agents access through API keys and OAuth flows that can be rotated and revoked.</p>
</article>
<article class="feature">
<h3>Tool permissions</h3>
<p>Expose only the tools a workspace should use, with visibility into who can call what.</p>
</article>
<article class="feature">
<h3>Audit logs</h3>
<p>Track every tool call so operators and regulated teams can review activity later.</p>
</article>
<article class="feature">
<h3>Observability</h3>
<p>See request volume, connector activity, and operational failures before pilots go sideways.</p>
</article>
</div>
</section>
<section class="band alt">
<div class="wrap section-head">
<div>
<div class="kicker">Ideal buyers</div>
<h2>Built for internal copilot teams first</h2>
</div>
<p class="section-copy">
The cleanest first wedge is internal support and operations copilots, where ROI is
easier to prove and governance matters early.
</p>
</div>
<div class="wrap buyer-grid">
<article class="buyer-card">
<h3>AI startups</h3>
<p>Need internal tool access quickly without building a full security layer from scratch.</p>
</article>
<article class="buyer-card">
<h3>Mid-market internal AI teams</h3>
<p>Need a controlled bridge between copilots and the systems employees already use.</p>
</article>
<article class="buyer-card">
<h3>Fintech and regulated teams</h3>
<p>Need logs, permissions, and operating discipline before exposing sensitive workflows.</p>
</article>
</div>
</section>
<section id="pricing" class="band">
<div class="wrap section-head">
<div>
<div class="kicker">Packaging</div>
<h2>Start with a paid pilot, then grow into recurring revenue</h2>
</div>
<p class="section-copy">
The early offer is a managed deployment, not a self-serve platform sale. Close pilots,
then learn which security and connector features unblock larger contracts.
</p>
</div>
<div class="wrap pricing-grid">
<article class="pricing-card">
<div class="pricing-tier">Pilot</div>
<div class="pricing-price">$5k-$10k setup</div>
<p class="pricing-copy">$500-$3k monthly once the initial deployment is live.</p>
<ul class="list">
<li>Hosted MCP endpoint</li>
<li>Auth and connector setup</li>
<li>Tool filtering and logs</li>
<li>Delivery support</li>
</ul>
</article>
<article class="pricing-card">
<div class="pricing-tier">Team</div>
<div class="pricing-price">$199-$499/mo</div>
<p class="pricing-copy">For smaller internal AI teams evaluating managed MCP access.</p>
<ul class="list">
<li>Up to 10 connectors</li>
<li>Role-based permissions</li>
<li>Audit logs</li>
<li>Email support</li>
</ul>
</article>
<article class="pricing-card">
<div class="pricing-tier">Business</div>
<div class="pricing-price">$1.5k-$3k/mo</div>
<p class="pricing-copy">For production deployments with stronger operating requirements.</p>
<ul class="list">
<li>SSO</li>
<li>Private networking</li>
<li>Longer retention</li>
<li>Alerts and SLA</li>
</ul>
</article>
</div>
</section>
<section id="pilot-form" class="band">
<div class="wrap form-grid">
<div>
<div class="kicker">SquareMCP pilot intake</div>
<h2>Collect a serious pilot request, not just an email click</h2>
<p class="section-copy">
This intake form builds a ready-to-send pilot request with the details you actually
need to qualify a first deployment for squaremcp.com.
</p>
</div>
<form class="pilot-form" id="pilotIntakeForm">
<input type="hidden" name="submission_tag" value="" />
<div class="field-grid">
<label>
<span>Name</span>
<input name="name" type="text" required />
</label>
<label>
<span>Work email</span>
<input name="email" type="email" required />
</label>
<label>
<span>Company</span>
<input name="company" type="text" required />
</label>
<label>
<span>Role</span>
<input name="role" type="text" required />
</label>
<label>
<span>Primary use case</span>
<select name="use_case" required>
<option value="">Select one</option>
<option>Internal support copilot</option>
<option>Operations workflow assistant</option>
<option>Internal developer tooling</option>
<option>Regulated knowledge access</option>
<option>Other</option>
</select>
</label>
<label>
<span>Expected timeline</span>
<select name="timeline" required>
<option value="">Select one</option>
<option>Within 2 weeks</option>
<option>This month</option>
<option>This quarter</option>
<option>Exploring only</option>
</select>
</label>
</div>
<label>
<span>Internal systems to connect</span>
<textarea
name="systems"
rows="4"
placeholder="Examples: Postgres, Zendesk, internal REST APIs, admin workflows"
required
></textarea>
</label>
<label>
<span>Security or compliance requirements</span>
<textarea
name="requirements"
rows="4"
placeholder="Examples: audit logs, SSO, network isolation, approval flows"
required
></textarea>
</label>
<div class="form-actions">
<button class="button primary" type="submit">Submit pilot request</button>
<button class="button secondary" type="button" id="copyRequestButton">Copy request</button>
</div>
<div class="form-output" id="pilotOutput" aria-live="polite">
Fill out the form to submit your SquareMCP pilot request.
</div>
</form>
</div>
</section>
</main>
<footer class="footer">
<div class="wrap footer-row">
<div>
<strong>SquareMCP</strong>
<span class="footer-copy">Managed MCP infrastructure for internal tools.</span>
</div>
<div class="footer-links">
<a class="footer-link" href="mailto:info@squaremcp.com">info@squaremcp.com</a>
<a class="footer-link" href="https://squaremcp.com">squaremcp.com</a>
</div>
</div>
</footer>
<script src="./script.js?v=20260424b"></script>
</body>
</html>

View File

@@ -0,0 +1,19 @@
server {
listen 8080;
server_name squaremcp.com www.squaremcp.com;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location = /index.html {
add_header Cache-Control "no-cache";
}
location ~* \.(css|js)$ {
add_header Cache-Control "public, max-age=3600";
}
}

41
product/site/privacy.html Normal file
View File

@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Privacy Policy — SquareMCP</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 680px; margin: 60px auto; padding: 0 24px; color: #111; line-height: 1.7; }
h1 { font-size: 1.6rem; margin-bottom: 0.25rem; }
.sub { color: #666; font-size: 0.9rem; margin-bottom: 2rem; }
h2 { font-size: 1.05rem; margin-top: 2rem; }
a { color: #111; }
nav { margin-bottom: 2rem; font-size: 0.9rem; }
nav a { text-decoration: none; color: #666; }
nav a:hover { color: #111; }
</style>
</head>
<body>
<nav><a href="/">← squaremcp.com</a></nav>
<h1>Privacy Policy</h1>
<p class="sub">SquareMCP &mdash; Last updated April 28, 2026</p>
<h2>What SquareMCP is</h2>
<p>SquareMCP is a personal MCP server platform that connects AI assistants to your own tools — email, notes, and internal systems. It is operated by Garfield Heron and is currently in private pilot.</p>
<h2>Data we collect</h2>
<p>We collect only what is necessary to operate the service: your name, email address, company, and use case when you submit a pilot request. This information is stored securely and used only to evaluate and onboard pilot participants.</p>
<h2>Data we do not collect</h2>
<p>SquareMCP does not use analytics, advertising trackers, or third-party data sharing of any kind. We do not sell or rent your information.</p>
<h2>Your data and your tools</h2>
<p>When you use SquareMCP to connect your own accounts (email, notes, calendars), those connections are authenticated via OAuth and run entirely within your own environment. Your data does not pass through SquareMCP infrastructure — the MCP server runs on your own host or a host you control.</p>
<h2>OAuth tokens</h2>
<p>OAuth access tokens used to authenticate AI sessions are stored in a private database on your server. They expire after 24 hours and are never shared with third parties.</p>
<h2>Contact</h2>
<p>Questions or requests: <a href="mailto:garfield@fetcherpay.com">garfield@fetcherpay.com</a></p>
</body>
</html>

97
product/site/script.js Normal file
View File

@@ -0,0 +1,97 @@
const form = document.getElementById("pilotIntakeForm");
const output = document.getElementById("pilotOutput");
const copyButton = document.getElementById("copyRequestButton");
const heroAnimation = document.getElementById("heroAnimation");
const submitEndpoint = "/api/pilot-request";
if (heroAnimation) {
const posterSrc = heroAnimation.dataset.posterSrc;
const playMs = Number(heroAnimation.dataset.playMs || "0");
if (posterSrc && playMs > 0) {
window.setTimeout(() => {
heroAnimation.src = posterSrc;
}, playMs);
}
}
function buildMessage(data) {
return [
"Pilot request for SquareMCP",
"",
`Name: ${data.name}`,
`Email: ${data.email}`,
`Company: ${data.company}`,
`Role: ${data.role}`,
`Primary use case: ${data.use_case}`,
`Timeline: ${data.timeline}`,
"",
"Internal systems to connect:",
data.systems,
"",
"Security or compliance requirements:",
data.requirements,
].join("\n");
}
function getFormData() {
const formData = new FormData(form);
return Object.fromEntries(formData.entries());
}
function setOutput(message, isReady = false) {
output.textContent = message;
output.classList.toggle("ready", isReady);
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
if (!form.reportValidity()) {
return;
}
const data = getFormData();
const message = buildMessage(data);
setOutput("Submitting your pilot request...", false);
try {
const response = await fetch(submitEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`submit failed (${response.status})`);
}
const result = await response.json();
setOutput(
`${message}\n\nSaved to SquareMCP intake successfully.${result.request_id ? `\nRequest ID: ${result.request_id}` : ""}`,
true
);
form.reset();
} catch {
setOutput(
`${message}\n\nSubmission failed. Copy the request below and send it to info@squaremcp.com manually.`,
true
);
}
});
copyButton.addEventListener("click", async () => {
if (!form.reportValidity()) {
return;
}
const message = buildMessage(getFormData());
try {
await navigator.clipboard.writeText(message);
setOutput(`${message}\n\nCopied to clipboard.`, true);
} catch {
setOutput(`${message}\n\nClipboard access failed. Copy the text manually.`, true);
}
});

68
product/site/server.mjs Normal file
View File

@@ -0,0 +1,68 @@
import http from "node:http";
import { createReadStream, existsSync } from "node:fs";
import { stat } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = __dirname;
const port = Number(process.env.PRODUCT_SITE_PORT || 4173);
const host = "127.0.0.1";
const contentTypes = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
};
function resolvePath(urlPath) {
const cleanPath = decodeURIComponent(urlPath.split("?")[0]);
const relativePath = cleanPath === "/" ? "/index.html" : cleanPath;
const absolutePath = path.normalize(path.join(root, relativePath));
if (!absolutePath.startsWith(root)) {
return null;
}
// Try with .html extension for clean URLs (e.g. /privacy → privacy.html)
const withHtml = absolutePath + ".html";
if (!existsSync(absolutePath) && existsSync(withHtml)) return withHtml;
return absolutePath;
}
const server = http.createServer(async (req, res) => {
const filePath = resolvePath(req.url || "/");
if (!filePath) {
res.writeHead(403);
res.end("Forbidden");
return;
}
try {
const fileStat = await stat(filePath);
if (!fileStat.isFile()) {
res.writeHead(404);
res.end("Not found");
return;
}
} catch {
const fallback = path.join(root, "index.html");
if (existsSync(fallback)) {
res.writeHead(200, { "Content-Type": contentTypes[".html"] });
createReadStream(fallback).pipe(res);
return;
}
res.writeHead(404);
res.end("Not found");
return;
}
const ext = path.extname(filePath).toLowerCase();
res.writeHead(200, {
"Content-Type": contentTypes[ext] || "application/octet-stream",
});
createReadStream(filePath).pipe(res);
});
server.listen(port, host, () => {
console.log(`SquareMCP product site running at http://${host}:${port}`);
});

208
product/site/smoke-test.cjs Normal file
View File

@@ -0,0 +1,208 @@
const fs = require("fs");
const path = require("path");
const vm = require("vm");
const scriptPath = path.join(__dirname, "script.js");
const scriptSource = fs.readFileSync(scriptPath, "utf8");
function createClassList() {
const values = new Set();
return {
toggle(name, enabled) {
if (enabled) values.add(name);
else values.delete(name);
},
has(name) {
return values.has(name);
},
};
}
function createForm(valid, entries) {
const listeners = {};
let resetCount = 0;
return {
__entries: entries,
reportValidity() {
return valid;
},
reset() {
resetCount += 1;
},
get resetCount() {
return resetCount;
},
addEventListener(type, handler) {
listeners[type] = handler;
},
dispatch(type, event) {
if (!listeners[type]) throw new Error(`missing listener: ${type}`);
return listeners[type](event);
},
};
}
function createButton() {
const listeners = {};
return {
addEventListener(type, handler) {
listeners[type] = handler;
},
dispatch(type, event) {
if (!listeners[type]) throw new Error(`missing listener: ${type}`);
return listeners[type](event);
},
};
}
function createEnv({ valid = true, fetchOk = true, clipboardFails = false } = {}) {
const timers = [];
const output = { textContent: "", classList: createClassList() };
const heroAnimation = {
src: "./squaremcp_launch.gif",
dataset: {
posterSrc: "./squaremcp_launch_poster.png",
playMs: "9600",
},
};
const entries = [
["name", "Casey"],
["email", "casey@example.com"],
["company", "SquareMCP Labs"],
["role", "Founder"],
["use_case", "Internal support copilot"],
["timeline", "Within 2 weeks"],
["systems", "Postgres, internal REST APIs"],
["requirements", "Audit logs and SSO"],
];
const form = createForm(valid, entries);
const button = createButton();
const fetchCalls = [];
let clipboardText = null;
const context = {
console,
window: {
setTimeout(fn, ms) {
timers.push({ fn, ms });
return timers.length;
},
},
document: {
getElementById(id) {
if (id === "pilotIntakeForm") return form;
if (id === "pilotOutput") return output;
if (id === "copyRequestButton") return button;
if (id === "heroAnimation") return heroAnimation;
return null;
},
},
navigator: {
clipboard: {
async writeText(value) {
if (clipboardFails) throw new Error("clipboard denied");
clipboardText = value;
},
},
},
fetch: async (url, options) => {
fetchCalls.push({ url, options });
if (!fetchOk) {
return {
ok: false,
status: 500,
async json() {
return { error: "submit failed" };
},
};
}
return {
ok: true,
status: 201,
async json() {
return { ok: true, request_id: "req-123" };
},
};
},
FormData: class MockFormData {
constructor(target) {
this.target = target;
}
entries() {
return this.target.__entries[Symbol.iterator]();
}
[Symbol.iterator]() {
return this.entries();
}
},
};
vm.createContext(context);
vm.runInContext(scriptSource, context, { filename: "script.js" });
return {
form,
button,
output,
heroAnimation,
timers,
fetchCalls,
getClipboardText: () => clipboardText,
};
}
function assert(condition, message) {
if (!condition) throw new Error(message);
}
async function run() {
const validEnv = createEnv();
assert(validEnv.timers.length === 1, "hero animation timer missing");
assert(validEnv.timers[0].ms === 9600, "hero animation timer mismatch");
validEnv.timers[0].fn();
assert(validEnv.heroAnimation.src === "./squaremcp_launch_poster.png", "hero poster swap failed");
let prevented = false;
await validEnv.form.dispatch("submit", {
preventDefault() {
prevented = true;
},
});
assert(prevented, "submit did not prevent default");
assert(validEnv.fetchCalls.length === 1, "submit did not call fetch");
assert(validEnv.fetchCalls[0].url === "/api/pilot-request", "submit endpoint mismatch");
assert(validEnv.fetchCalls[0].options.method === "POST", "submit method mismatch");
const payload = JSON.parse(validEnv.fetchCalls[0].options.body);
assert(payload.company === "SquareMCP Labs", "submit payload mismatch");
assert(validEnv.form.resetCount === 1, "form did not reset after success");
assert(validEnv.output.textContent.includes("Saved to SquareMCP intake successfully."), "submit success message missing");
assert(validEnv.output.classList.has("ready"), "submit did not set ready class");
await validEnv.button.dispatch("click", {});
assert(validEnv.getClipboardText().includes("SquareMCP Labs"), "copy handler did not write clipboard");
assert(validEnv.output.textContent.includes("Copied to clipboard."), "copy success message missing");
const invalidEnv = createEnv({ valid: false });
await invalidEnv.form.dispatch("submit", { preventDefault() {} });
assert(invalidEnv.fetchCalls.length === 0, "invalid submit should not call fetch");
assert(invalidEnv.output.textContent === "", "invalid submit should not update output");
const failureEnv = createEnv({ fetchOk: false });
await failureEnv.form.dispatch("submit", { preventDefault() {} });
assert(failureEnv.output.textContent.includes("Submission failed."), "submit failure message missing");
assert(failureEnv.form.resetCount === 0, "failed submit should not reset form");
const clipboardFailureEnv = createEnv({ clipboardFails: true });
await clipboardFailureEnv.button.dispatch("click", {});
assert(
clipboardFailureEnv.output.textContent.includes("Clipboard access failed."),
"clipboard failure message missing"
);
console.log("squaremcp product site smoke test: PASS");
}
run().catch((error) => {
console.error(`squaremcp product site smoke test: FAIL: ${error.message}`);
process.exit(1);
});

View File

@@ -0,0 +1,13 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: squaremcp-tls
namespace: fetcherpay
spec:
secretName: squaremcp-tls
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
dnsNames:
- squaremcp.com
- www.squaremcp.com

View File

@@ -0,0 +1,97 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: squaremcp-site
namespace: fetcherpay
spec:
replicas: 1
selector:
matchLabels:
app: squaremcp-site
template:
metadata:
labels:
app: squaremcp-site
spec:
containers:
- name: squaremcp-site
image: localhost:32000/squaremcp-site:latest
imagePullPolicy: Always
ports:
- containerPort: 8080
readinessProbe:
httpGet:
path: /
port: 8080
initialDelaySeconds: 3
periodSeconds: 10
livenessProbe:
httpGet:
path: /
port: 8080
initialDelaySeconds: 10
periodSeconds: 30
---
apiVersion: v1
kind: Service
metadata:
name: squaremcp-site
namespace: fetcherpay
spec:
selector:
app: squaremcp-site
ports:
- protocol: TCP
port: 80
targetPort: 8080
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: squaremcp-site-ingress
namespace: fetcherpay
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/proxy-buffering: "off"
spec:
ingressClassName: nginx
rules:
- host: squaremcp.com
http:
paths:
- path: /api/pilot-request
pathType: Prefix
backend:
service:
name: hermes-mcp
port:
number: 3456
- path: /
pathType: Prefix
backend:
service:
name: squaremcp-site
port:
number: 80
- host: www.squaremcp.com
http:
paths:
- path: /api/pilot-request
pathType: Prefix
backend:
service:
name: hermes-mcp
port:
number: 3456
- path: /
pathType: Prefix
backend:
service:
name: squaremcp-site
port:
number: 80
tls:
- hosts:
- squaremcp.com
- www.squaremcp.com
secretName: squaremcp-tls

View File

@@ -0,0 +1,29 @@
# K8s Ingress for mail.squaremcp.com
# Routes webmail/admin traffic to the existing poste.io service in the email namespace
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: squaremcp-mail-ingress
namespace: email
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/proxy-body-size: "50m"
nginx.ingress.kubernetes.io/ssl-redirect: "false"
spec:
ingressClassName: nginx
rules:
- host: mail.squaremcp.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: complete-mail-service
port:
number: 80
tls:
- hosts:
- mail.squaremcp.com
secretName: mail-squaremcp-tls

Binary file not shown.

After

Width:  |  Height:  |  Size: 584 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

520
product/site/styles.css Normal file
View File

@@ -0,0 +1,520 @@
:root {
color-scheme: light;
--bg: #f3f6fb;
--surface: #ffffff;
--surface-alt: #e9effc;
--surface-strong: #dfe8fb;
--text: #132038;
--muted: #57657c;
--line: #cfd8ea;
--accent: #0e63f6;
--accent-strong: #0a49c2;
--accent-soft: #e3edff;
--success: #0e7a53;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
font-family: Inter, Arial, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
}
a {
color: inherit;
}
.topbar {
position: sticky;
top: 0;
z-index: 10;
border-bottom: 1px solid rgba(207, 216, 234, 0.7);
background: rgba(243, 246, 251, 0.92);
backdrop-filter: blur(12px);
}
.topbar-row {
min-height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.brand {
display: inline-flex;
align-items: center;
gap: 10px;
text-decoration: none;
font-weight: 800;
}
.brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 8px;
background: var(--accent);
color: #fff;
}
.brand-text {
color: var(--text);
}
.topbar-actions {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.topbar-link {
color: var(--muted);
text-decoration: none;
font-weight: 600;
}
.topbar-link:hover {
color: var(--accent-strong);
}
.wrap {
width: min(1160px, calc(100% - 32px));
margin: 0 auto;
}
.hero {
padding: 72px 0 52px;
background:
radial-gradient(circle at top right, #d9e6ff 0%, transparent 32%),
linear-gradient(180deg, #f8fbff 0%, #eaf1ff 100%);
border-bottom: 1px solid var(--line);
}
.hero-grid,
.feature-grid,
.buyer-grid,
.pricing-grid,
.launch-grid,
.form-grid,
.field-grid {
display: grid;
gap: 20px;
}
.hero-grid {
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 420px);
align-items: start;
}
.hero-copy {
padding-right: 8px;
}
.eyebrow,
.kicker,
.panel-topline,
.pricing-tier {
font-size: 13px;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
.eyebrow,
.kicker,
.panel-topline {
color: var(--accent-strong);
}
h1,
h2,
h3 {
margin: 0 0 12px;
}
h1 {
max-width: 760px;
font-size: 56px;
line-height: 1.02;
}
h2 {
font-size: 34px;
line-height: 1.1;
}
h3 {
font-size: 20px;
}
.lede,
.section-copy,
.pricing-copy,
.buyer-card p,
.feature p,
.launch-note p {
color: var(--muted);
}
.lede {
max-width: 760px;
font-size: 20px;
}
.hero-points {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 22px;
}
.hero-points span {
min-height: 36px;
padding: 8px 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(255, 255, 255, 0.8);
font-size: 14px;
color: var(--muted);
}
.hero-contact {
margin-top: 18px;
color: var(--muted);
}
.hero-contact a,
.footer-link {
color: var(--accent-strong);
text-decoration: none;
}
.hero-contact a:hover,
.footer-link:hover {
text-decoration: underline;
}
.actions,
.form-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.actions {
margin-top: 24px;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0 18px;
border: 1px solid transparent;
border-radius: 8px;
text-decoration: none;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.button.primary {
background: var(--accent);
color: #fff;
}
.button.primary:hover {
background: var(--accent-strong);
}
.button.secondary {
background: var(--surface);
color: var(--text);
border-color: var(--line);
}
.button.secondary:hover {
background: #f9fbff;
}
.hero-panel,
.feature,
.buyer-card,
.pricing-card,
.launch-note,
.pilot-form {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
}
.hero-panel {
padding: 22px;
box-shadow: 0 18px 40px rgba(22, 41, 76, 0.08);
}
.hero-media {
margin: -6px -6px 18px;
border: 1px solid var(--line);
border-radius: 8px;
overflow: hidden;
background: #08111f;
}
.hero-media img {
display: block;
width: 100%;
height: auto;
object-fit: contain;
}
.signal-list,
.list,
.sequence {
margin: 0;
padding-left: 20px;
}
.signal-list li,
.list li,
.sequence li {
margin-bottom: 10px;
}
.metric-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 18px;
}
.metric {
min-height: 88px;
padding: 14px;
border-radius: 8px;
background: var(--accent-soft);
}
.metric-value {
display: block;
font-size: 26px;
font-weight: 800;
color: var(--accent-strong);
}
.metric-label {
display: block;
margin-top: 4px;
color: var(--muted);
font-size: 14px;
}
.band {
padding: 56px 0;
}
.band.alt {
background: var(--surface-alt);
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
.section-head {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 420px);
gap: 24px;
align-items: end;
margin-bottom: 24px;
}
.feature-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.feature,
.buyer-card,
.pricing-card,
.launch-note {
padding: 20px;
}
.buyer-grid,
.pricing-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.pricing-price {
font-size: 28px;
font-weight: 800;
color: var(--accent-strong);
margin-bottom: 10px;
}
.launch-grid {
grid-template-columns: minmax(0, 1.3fr) minmax(280px, 360px);
align-items: start;
}
.form-grid {
grid-template-columns: minmax(0, 0.8fr) minmax(360px, 1.2fr);
align-items: start;
}
.pilot-form {
padding: 22px;
}
.field-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
label {
display: flex;
flex-direction: column;
gap: 8px;
font-weight: 600;
}
input,
select,
textarea {
width: 100%;
min-height: 44px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
color: var(--text);
font: inherit;
}
textarea {
min-height: 116px;
resize: vertical;
}
input:focus,
select:focus,
textarea:focus {
outline: 2px solid rgba(14, 99, 246, 0.18);
border-color: var(--accent);
}
.form-actions {
margin-top: 18px;
}
.form-output {
margin-top: 18px;
padding: 16px;
border-radius: 8px;
background: #f8fbff;
border: 1px solid var(--line);
color: var(--muted);
white-space: pre-wrap;
}
.form-output.ready {
border-color: #b9d8ca;
background: #f3fbf6;
color: var(--success);
}
.footer {
position: relative;
z-index: 1;
border-top: 1px solid var(--line);
background: #eef3fb;
overflow: hidden;
}
.footer-row {
min-height: 72px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding-top: 18px;
padding-bottom: 18px;
}
.footer-copy {
margin-left: 10px;
color: var(--muted);
display: inline-block;
}
.footer-links {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
@media (max-width: 980px) {
h1 {
font-size: 42px;
}
h2 {
font-size: 28px;
}
.hero-grid,
.section-head,
.launch-grid,
.form-grid,
.feature-grid,
.buyer-grid,
.pricing-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.field-grid,
.metric-row {
grid-template-columns: 1fr;
}
.topbar-row {
min-height: auto;
align-items: flex-start;
justify-content: flex-start;
flex-direction: column;
padding: 14px 0;
}
.topbar-actions {
width: 100%;
}
.footer-row {
align-items: flex-start;
justify-content: flex-start;
flex-direction: column;
padding: 18px 0;
}
.lede {
font-size: 18px;
}
.band {
padding: 44px 0;
}
}

62
product/site/verify.mjs Normal file
View File

@@ -0,0 +1,62 @@
import { spawn } from "child_process";
const commands = [
{
label: "smoke",
cmd: "node",
args: ["product/site/smoke-test.cjs"],
env: {},
},
{
label: "e2e-desktop",
cmd: "node",
args: ["product/site/e2e-test.mjs"],
env: {
SQUAREMCP_E2E_PROFILE: "desktop",
SQUAREMCP_E2E_SCREENSHOT: "/tmp/squaremcp-e2e-desktop.png",
},
},
{
label: "e2e-mobile",
cmd: "node",
args: ["product/site/e2e-test.mjs"],
env: {
SQUAREMCP_E2E_PROFILE: "mobile",
SQUAREMCP_E2E_SCREENSHOT: "/tmp/squaremcp-e2e-mobile.png",
},
},
];
function runCommand({ label, cmd, args, env }) {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, {
stdio: "inherit",
env: { ...process.env, ...env },
});
child.on("exit", (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(`${label} failed with exit code ${code}`));
});
child.on("error", reject);
});
}
async function main() {
for (const command of commands) {
console.log(`\n==> ${command.label}`);
await runCommand(command);
}
console.log("\nsquaremcp product site verification: PASS");
console.log("desktop_screenshot: /tmp/squaremcp-e2e-desktop.png");
console.log("mobile_screenshot: /tmp/squaremcp-e2e-mobile.png");
}
main().catch((error) => {
console.error(`squaremcp product site verification: FAIL: ${error.message}`);
process.exit(1);
});