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:
9
product/site/Dockerfile
Normal file
9
product/site/Dockerfile
Normal 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
|
||||
64
product/site/VERIFICATION.md
Normal file
64
product/site/VERIFICATION.md
Normal 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
|
||||
```
|
||||
BIN
product/site/baselines/desktop.png
Normal file
BIN
product/site/baselines/desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 662 KiB |
BIN
product/site/baselines/mobile.png
Normal file
BIN
product/site/baselines/mobile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 576 KiB |
47
product/site/cleanup-test-submissions.mjs
Normal file
47
product/site/cleanup-test-submissions.mjs
Normal 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}`);
|
||||
53
product/site/compare-screenshot.mjs
Normal file
53
product/site/compare-screenshot.mjs
Normal 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}`
|
||||
);
|
||||
35
product/site/deploy-and-verify.sh
Executable file
35
product/site/deploy-and-verify.sh
Executable 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
257
product/site/e2e-test.mjs
Normal 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
303
product/site/index.html
Normal 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>
|
||||
19
product/site/nginx-site.conf
Normal file
19
product/site/nginx-site.conf
Normal 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
41
product/site/privacy.html
Normal 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 — 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
97
product/site/script.js
Normal 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
68
product/site/server.mjs
Normal 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
208
product/site/smoke-test.cjs
Normal 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);
|
||||
});
|
||||
13
product/site/squaremcp-certificate.yaml
Normal file
13
product/site/squaremcp-certificate.yaml
Normal 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
|
||||
97
product/site/squaremcp-k8s-ingress.yaml
Normal file
97
product/site/squaremcp-k8s-ingress.yaml
Normal 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
|
||||
29
product/site/squaremcp-mail-ingress.yaml
Normal file
29
product/site/squaremcp-mail-ingress.yaml
Normal 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
|
||||
BIN
product/site/squaremcp_launch.gif
Normal file
BIN
product/site/squaremcp_launch.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 584 KiB |
BIN
product/site/squaremcp_launch_poster.png
Normal file
BIN
product/site/squaremcp_launch_poster.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 139 KiB |
520
product/site/styles.css
Normal file
520
product/site/styles.css
Normal 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
62
product/site/verify.mjs
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user