Attempt 2 of a management ui.
This commit is contained in:
54 files changed
+8804
-267
No files matched your search
@@ -0,0 +1,30 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
/**
|
||||
* A single, dedicated platform-operator credential via env vars — not
|
||||
* tied to any one business's own Super Administrator (each business has
|
||||
* its own separate database; this tool has no "primary" one of its own,
|
||||
* so there's no single business account it could sensibly check against).
|
||||
* Bcrypt cost 12 and the dummy-hash timing-safe comparison mirror the
|
||||
* main app's own convention (see scripts/ensure-super-admin.ts and
|
||||
* src/auth.ts) — copied, not imported: this app never imports from the
|
||||
* main app's src/ at all (see session.ts's header for why).
|
||||
*/
|
||||
|
||||
// A real bcrypt hash of an unguessable value, used only so a request for
|
||||
// a wrong/missing MANAGER_EMAIL still pays the same bcrypt.compare cost
|
||||
// as a wrong password — otherwise "no such email" and "wrong password"
|
||||
// are distinguishable by response time.
|
||||
const DUMMY_HASH = bcrypt.hashSync("nextchair-manager-dummy-hash", 12);
|
||||
|
||||
export async function verifyManagerCredentials(email: string, password: string): Promise<boolean> {
|
||||
const expectedEmail = process.env.MANAGER_EMAIL?.trim().toLowerCase();
|
||||
const expectedHash = process.env.MANAGER_PASSWORD_HASH;
|
||||
if (!expectedEmail || !expectedHash) {
|
||||
throw new Error("MANAGER_EMAIL and MANAGER_PASSWORD_HASH must both be set.");
|
||||
}
|
||||
|
||||
const matchesEmail = email.trim().toLowerCase() === expectedEmail;
|
||||
const valid = await bcrypt.compare(password, matchesEmail ? expectedHash : DUMMY_HASH);
|
||||
return matchesEmail && valid;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Generates the exact same shape as stacks/blake-heritage/compose.yaml
|
||||
* and stacks/marios/compose.yaml (the two hand-written example stacks) —
|
||||
* keep these in sync if that shape changes.
|
||||
*/
|
||||
export function generateComposeYaml(slug: string): string {
|
||||
const containerName = `next-chair-${slug}`;
|
||||
const volumeName = `next-chair-media-${slug}`;
|
||||
return `name: ${containerName}
|
||||
|
||||
# Generated by the management UI — see stacks/blake-heritage/compose.yaml
|
||||
# for the hand-written original this mirrors.
|
||||
|
||||
services:
|
||||
${containerName}:
|
||||
image: \${NEXTCHAIR_IMAGE:-nextchair-app:latest}
|
||||
container_name: ${containerName}
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ${volumeName}:/app/storage/media
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"require('http').get('http://localhost:3000/api/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 20s
|
||||
networks:
|
||||
- next-chair-edge
|
||||
|
||||
volumes:
|
||||
${volumeName}:
|
||||
name: ${volumeName}
|
||||
|
||||
networks:
|
||||
next-chair-edge:
|
||||
name: next-chair-edge
|
||||
external: true
|
||||
`;
|
||||
}
|
||||
|
||||
export interface BusinessEnvValues {
|
||||
domain: string;
|
||||
databaseName: string;
|
||||
databaseUser: string;
|
||||
databasePassword: string;
|
||||
authSecret: string;
|
||||
installationToken: string;
|
||||
cronSecret: string;
|
||||
}
|
||||
|
||||
/** Same shape as .env.business.example, filled in with generated/collected values instead of placeholders. */
|
||||
export function generateBusinessEnv(values: BusinessEnvValues): string {
|
||||
return `# Generated by the management UI. See .env.business.example for what
|
||||
# each of these does.
|
||||
DOMAIN="${values.domain}"
|
||||
DATABASE_HOST="next-chair-mysql"
|
||||
DATABASE_PORT="3306"
|
||||
DATABASE_NAME="${values.databaseName}"
|
||||
DATABASE_USER="${values.databaseUser}"
|
||||
DATABASE_PASSWORD="${values.databasePassword}"
|
||||
AUTH_SECRET="${values.authSecret}"
|
||||
AUTH_TRUST_HOST="true"
|
||||
NEXT_PUBLIC_APP_URL="https://${values.domain}"
|
||||
INSTALLATION_TOKEN="${values.installationToken}"
|
||||
CRON_SECRET="${values.cronSecret}"
|
||||
|
||||
SUPER_ADMIN_EMAIL=""
|
||||
SUPER_ADMIN_PASSWORD=""
|
||||
SUPER_ADMIN_FIRST_NAME=""
|
||||
SUPER_ADMIN_LAST_NAME=""
|
||||
|
||||
AUTH_GOOGLE_ID=""
|
||||
AUTH_GOOGLE_SECRET=""
|
||||
|
||||
GOOGLE_CALENDAR_CLIENT_ID=""
|
||||
GOOGLE_CALENDAR_CLIENT_SECRET=""
|
||||
GOOGLE_CALENDAR_REDIRECT_URI="https://${values.domain}/api/staff/calendar/google/callback"
|
||||
CALENDAR_TOKEN_ENCRYPTION_KEY=""
|
||||
|
||||
MEDIA_MAX_UPLOAD_BYTES="5000000"
|
||||
|
||||
GOOGLE_PLACES_API_KEY=""
|
||||
|
||||
EMAIL_PROVIDER="console"
|
||||
EMAIL_SMTP_HOST="smtp-relay.brevo.com"
|
||||
EMAIL_SMTP_PORT="587"
|
||||
EMAIL_SMTP_USERNAME=""
|
||||
EMAIL_SMTP_PASSWORD=""
|
||||
EMAIL_FROM_ADDRESS=""
|
||||
EMAIL_FROM_NAME=""
|
||||
|
||||
PUSH_PROVIDER="console"
|
||||
VAPID_PUBLIC_KEY=""
|
||||
NEXT_PUBLIC_VAPID_PUBLIC_KEY=""
|
||||
VAPID_PRIVATE_KEY=""
|
||||
VAPID_SUBJECT="mailto:owner@example.com"
|
||||
`;
|
||||
}
|
||||
|
||||
export function generateCaddySite(slug: string, domain: string): string {
|
||||
return `${domain} {
|
||||
reverse_proxy next-chair-${slug}:3000
|
||||
}
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Every call here runs `docker compose` with `cwd` set to a stack's own
|
||||
* directory (stacks/<slug>/) rather than passing explicit -f/--env-file
|
||||
* flags — compose already auto-loads `compose.yaml` and `.env` from the
|
||||
* current directory, and this is the same convention Dockge itself uses,
|
||||
* so a stack behaves identically whether it's started by this app, by
|
||||
* Dockge, or by hand from the command line.
|
||||
*
|
||||
* This app's own container needs the Docker socket mounted and the
|
||||
* `docker compose` CLI installed (see manager/Dockerfile) — these calls
|
||||
* are executed by the HOST's dockerd via that socket, not by a nested
|
||||
* Docker daemon inside this container.
|
||||
*/
|
||||
|
||||
export interface DockerCommandResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
async function runDockerCompose(stackDir: string, args: string[]): Promise<DockerCommandResult> {
|
||||
const result = await execFileAsync("docker", ["compose", ...args], {
|
||||
cwd: stackDir,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function composeUp(stackDir: string, options: { forceRecreate?: boolean } = {}): Promise<void> {
|
||||
const args = ["up", "-d"];
|
||||
if (options.forceRecreate) args.push("--force-recreate");
|
||||
await runDockerCompose(stackDir, args);
|
||||
}
|
||||
|
||||
export async function composeDown(stackDir: string): Promise<void> {
|
||||
await runDockerCompose(stackDir, ["down"]);
|
||||
}
|
||||
|
||||
export async function composeRestart(stackDir: string): Promise<void> {
|
||||
await runDockerCompose(stackDir, ["restart"]);
|
||||
}
|
||||
|
||||
export async function composeLogs(stackDir: string, tailLines = 200): Promise<string> {
|
||||
const result = await runDockerCompose(stackDir, ["logs", "--no-color", "--tail", String(tailLines)]);
|
||||
// Compose writes container logs to stderr, not stdout — this is normal
|
||||
// (docker's own `logs` command does the same), not a failure signal.
|
||||
return result.stdout + result.stderr;
|
||||
}
|
||||
|
||||
export type StackRunState = "running" | "stopped" | "unknown";
|
||||
|
||||
/** Best-effort — a stack that's never been started at all reports "stopped", not an error. */
|
||||
export async function composeStatus(stackDir: string): Promise<StackRunState> {
|
||||
try {
|
||||
const result = await runDockerCompose(stackDir, ["ps", "--status", "running", "--format", "json"]);
|
||||
return result.stdout.trim().length > 0 ? "running" : "stopped";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export async function composeExec(stackDir: string, serviceName: string, command: string[]): Promise<void> {
|
||||
await runDockerCompose(stackDir, ["exec", "-T", serviceName, ...command]);
|
||||
}
|
||||
|
||||
/** Applies pending Prisma migrations inside a freshly-started business container — retries briefly since the container's Node process may take a moment to come up after `composeUp`. */
|
||||
export async function runMigrations(stackDir: string, serviceName: string): Promise<void> {
|
||||
const maxAttempts = 6;
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
try {
|
||||
await composeExec(stackDir, serviceName, ["npx", "prisma", "migrate", "deploy"]);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
throw lastError instanceof Error ? lastError : new Error("Migrations failed after retries.");
|
||||
}
|
||||
|
||||
/** Caddy doesn't watch its sites/ directory — adding or removing a business's .caddy file needs this to take effect. */
|
||||
export async function reloadCaddy(): Promise<void> {
|
||||
const containerName = process.env.CADDY_CONTAINER_NAME ?? "next-chair-caddy";
|
||||
await execFileAsync("docker", ["exec", containerName, "caddy", "reload", "--config", "/etc/caddy/Caddyfile"]);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import mariadb from "mariadb";
|
||||
import { composeUp, reloadCaddy, runMigrations } from "./docker";
|
||||
import { generateBusinessEnv, generateCaddySite, generateComposeYaml } from "./compose-template";
|
||||
import { isValidSlug, stackDir, stackExists, stacksRoot } from "./stacks";
|
||||
|
||||
/** Hard safety check on top of isValidSlug (which already constrains the slug itself) — this is the one place that string ends up inside a raw SQL statement, where an identifier can't be parameter-bound the way a value can. */
|
||||
const SAFE_DB_IDENTIFIER = /^[a-z][a-z0-9_]{1,48}[a-z0-9]$/;
|
||||
/** Generated via randomBytes/hex below — always matches this, checked anyway as defense in depth before it goes into a SQL string literal. */
|
||||
const SAFE_GENERATED_SECRET = /^[a-f0-9]+$/;
|
||||
|
||||
function randomHex(bytes: number): string {
|
||||
return randomBytes(bytes).toString("hex");
|
||||
}
|
||||
|
||||
export interface ProvisionBusinessInput {
|
||||
slug: string;
|
||||
domain: string;
|
||||
}
|
||||
|
||||
export interface ProvisionBusinessResult {
|
||||
slug: string;
|
||||
domain: string;
|
||||
installUrl: string;
|
||||
}
|
||||
|
||||
async function createDatabaseAndUser(databaseName: string, databaseUser: string, databasePassword: string): Promise<void> {
|
||||
if (!SAFE_DB_IDENTIFIER.test(databaseName) || !SAFE_DB_IDENTIFIER.test(databaseUser)) {
|
||||
throw new Error("Refusing to create a database/user from an unsafe identifier.");
|
||||
}
|
||||
if (!SAFE_GENERATED_SECRET.test(databasePassword)) {
|
||||
throw new Error("Refusing to create a user with an unexpected password format.");
|
||||
}
|
||||
|
||||
const host = process.env.MYSQL_HOST ?? "next-chair-mysql";
|
||||
const port = process.env.MYSQL_PORT ? Number(process.env.MYSQL_PORT) : 3306;
|
||||
const rootPassword = process.env.MYSQL_ROOT_PASSWORD;
|
||||
if (!rootPassword) throw new Error("MYSQL_ROOT_PASSWORD must be set.");
|
||||
|
||||
const conn = await mariadb.createConnection({
|
||||
host,
|
||||
port,
|
||||
user: "root",
|
||||
password: rootPassword,
|
||||
// MySQL 8's default auth plugin (caching_sha2_password) needs to send
|
||||
// the client its RSA public key to encrypt the password, and the
|
||||
// mariadb driver refuses to fetch it over a plain connection unless
|
||||
// told to — see the main app's src/lib/db/mysql-pool-config.ts for
|
||||
// the full explanation of this exact error. Confirmed by hitting it
|
||||
// live: creating a business failed with "RSA public key is not
|
||||
// available client side" before this option was added here.
|
||||
allowPublicKeyRetrieval: true,
|
||||
});
|
||||
try {
|
||||
// Identifiers are backtick-quoted AND already validated against
|
||||
// SAFE_DB_IDENTIFIER above — belt and braces, since a SQL identifier
|
||||
// (unlike a value) can't be parameter-bound.
|
||||
await conn.query(`CREATE DATABASE \`${databaseName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci`);
|
||||
await conn.query(`CREATE USER '${databaseUser}'@'%' IDENTIFIED BY '${databasePassword}'`);
|
||||
await conn.query(`GRANT ALL PRIVILEGES ON \`${databaseName}\`.* TO '${databaseUser}'@'%'`);
|
||||
await conn.query("FLUSH PRIVILEGES");
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
}
|
||||
|
||||
export async function provisionBusiness(input: ProvisionBusinessInput): Promise<ProvisionBusinessResult> {
|
||||
const slug = input.slug.trim().toLowerCase();
|
||||
const domain = input.domain.trim().toLowerCase();
|
||||
|
||||
if (!isValidSlug(slug)) {
|
||||
throw new Error("Slug must be lowercase letters, numbers, and hyphens, 3-50 characters, not a reserved name.");
|
||||
}
|
||||
if (!domain || domain.includes(" ")) {
|
||||
throw new Error("A valid domain is required.");
|
||||
}
|
||||
if (await stackExists(slug)) {
|
||||
throw new Error(`A stack named "${slug}" already exists.`);
|
||||
}
|
||||
|
||||
const databaseIdentifier = slug.replace(/-/g, "_");
|
||||
const databasePassword = randomHex(24);
|
||||
const authSecret = randomHex(32);
|
||||
const installationToken = randomHex(32);
|
||||
const cronSecret = randomHex(32);
|
||||
|
||||
await createDatabaseAndUser(databaseIdentifier, databaseIdentifier, databasePassword);
|
||||
|
||||
const dir = stackDir(slug);
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(path.join(dir, "compose.yaml"), generateComposeYaml(slug), "utf8");
|
||||
await writeFile(
|
||||
path.join(dir, ".env"),
|
||||
generateBusinessEnv({
|
||||
domain,
|
||||
databaseName: databaseIdentifier,
|
||||
databaseUser: databaseIdentifier,
|
||||
databasePassword,
|
||||
authSecret,
|
||||
installationToken,
|
||||
cronSecret,
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const sitesDir = path.join(stacksRoot(), "infra", "sites");
|
||||
await mkdir(sitesDir, { recursive: true });
|
||||
await writeFile(path.join(sitesDir, `${slug}.caddy`), generateCaddySite(slug, domain), "utf8");
|
||||
await reloadCaddy();
|
||||
|
||||
await composeUp(dir);
|
||||
await runMigrations(dir, `next-chair-${slug}`);
|
||||
|
||||
return { slug, domain, installUrl: `https://${domain}/install` };
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Signed-cookie session for the manager app — adapted from (not imported
|
||||
* from) the main NextChair app's src/lib/booking-draft/sign.ts and
|
||||
* src/lib/security/secrets-match.ts. Deliberately copied rather than
|
||||
* shared: this app must never import anything from the main app's src/ —
|
||||
* a previous attempt at this exact tool was built as routes inside the
|
||||
* main app and repeatedly broke from transitively importing Prisma/
|
||||
* Auth.js/business data into a mode that has no business database at
|
||||
* all — see ../../README.md's "Why it's a separate app" for the full
|
||||
* history. Keeping this app's module graph fully separate is what makes
|
||||
* that failure structurally impossible to repeat here.
|
||||
*
|
||||
* HMAC-signed (tamper-evident), not encrypted — the payload is just an
|
||||
* email and an expiry, nothing sensitive to disclose to the browser
|
||||
* holding its own cookie. Session length: 7 days.
|
||||
*/
|
||||
|
||||
const CONTEXT = "nextchair-manager-session-v1";
|
||||
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export interface ManagerSession {
|
||||
email: string;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
function getSigningKey(): Buffer {
|
||||
const secret = process.env.MANAGER_SESSION_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error("MANAGER_SESSION_SECRET must be set.");
|
||||
}
|
||||
return createHmac("sha256", secret).update(CONTEXT).digest();
|
||||
}
|
||||
|
||||
function sign(payload: string): string {
|
||||
const mac = createHmac("sha256", getSigningKey()).update(payload).digest("base64url");
|
||||
const encodedPayload = Buffer.from(payload, "utf8").toString("base64url");
|
||||
return `${encodedPayload}.${mac}`;
|
||||
}
|
||||
|
||||
function verifyToken(token: string): string | null {
|
||||
const separatorIndex = token.indexOf(".");
|
||||
if (separatorIndex === -1) return null;
|
||||
|
||||
const encodedPayload = token.slice(0, separatorIndex);
|
||||
const mac = token.slice(separatorIndex + 1);
|
||||
if (!encodedPayload || !mac) return null;
|
||||
|
||||
let payload: string;
|
||||
try {
|
||||
payload = Buffer.from(encodedPayload, "base64url").toString("utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const expectedMac = createHmac("sha256", getSigningKey()).update(payload).digest("base64url");
|
||||
const macBuffer = Buffer.from(mac);
|
||||
const expectedBuffer = Buffer.from(expectedMac);
|
||||
if (macBuffer.length !== expectedBuffer.length) return null;
|
||||
if (!timingSafeEqual(macBuffer, expectedBuffer)) return null;
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function createSessionToken(email: string): string {
|
||||
const payload: ManagerSession = { email, exp: Date.now() + SESSION_TTL_MS };
|
||||
return sign(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
/** Returns the session if the token is validly signed and not expired, otherwise null. Never throws on malformed input. */
|
||||
export function verifySessionToken(token: string | undefined): ManagerSession | null {
|
||||
if (!token) return null;
|
||||
const payload = verifyToken(token);
|
||||
if (!payload) return null;
|
||||
|
||||
let session: ManagerSession;
|
||||
try {
|
||||
session = JSON.parse(payload);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof session.email !== "string" || typeof session.exp !== "number") return null;
|
||||
if (Date.now() > session.exp) return null;
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same signal/reasoning as the main app's src/lib/security/cookie-
|
||||
* security.ts's isSecureDeployment() — NODE_ENV=production doesn't mean
|
||||
* TLS is actually in front of this app, so the cookie's Secure attribute
|
||||
* is derived from the deployer-declared public URL instead.
|
||||
*/
|
||||
export function isSecureDeployment(): boolean {
|
||||
return (process.env.MANAGER_PUBLIC_URL ?? "").startsWith("https://");
|
||||
}
|
||||
|
||||
export const SESSION_COOKIE_NAME = "manager_session";
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Pure string logic, deliberately kept free of any Node-only import
|
||||
* (fs, child_process) so it's safe to use from both server code
|
||||
* (stacks.ts, provisioning.ts) and client components (the Add Business
|
||||
* form's live slug suggestion) without pulling Node APIs into a browser
|
||||
* bundle.
|
||||
*/
|
||||
|
||||
export const RESERVED_SLUGS = new Set(["infra", "manager"]);
|
||||
|
||||
const SLUG_PATTERN = /^[a-z][a-z0-9-]{1,48}[a-z0-9]$/;
|
||||
|
||||
export function isValidSlug(slug: string): boolean {
|
||||
return SLUG_PATTERN.test(slug) && !RESERVED_SLUGS.has(slug);
|
||||
}
|
||||
|
||||
export function suggestSlug(businessName: string): string {
|
||||
return businessName
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 48);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { readdir, readFile, access } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { composeStatus, type StackRunState } from "./docker";
|
||||
import { RESERVED_SLUGS } from "./slug";
|
||||
|
||||
export { isValidSlug, suggestSlug } from "./slug";
|
||||
|
||||
export interface BusinessStackSummary {
|
||||
slug: string;
|
||||
domain: string | null;
|
||||
containerName: string;
|
||||
status: StackRunState;
|
||||
}
|
||||
|
||||
export function stacksRoot(): string {
|
||||
const dir = process.env.STACKS_DIR;
|
||||
if (!dir) throw new Error("STACKS_DIR must be set.");
|
||||
return dir;
|
||||
}
|
||||
|
||||
export function stackDir(slug: string): string {
|
||||
return path.join(stacksRoot(), slug);
|
||||
}
|
||||
|
||||
/** Only ever reads the DOMAIN line — deliberately never returns the rest of a business's .env (database password, AUTH_SECRET, etc.) to the UI. */
|
||||
async function readDomain(envPath: string): Promise<string | null> {
|
||||
try {
|
||||
const content = await readFile(envPath, "utf8");
|
||||
for (const line of content.split("\n")) {
|
||||
const match = /^DOMAIN\s*=\s*"?([^"\n]*)"?\s*$/.exec(line.trim());
|
||||
if (match?.[1]) return match[1];
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listBusinessStacks(): Promise<BusinessStackSummary[]> {
|
||||
const root = stacksRoot();
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(root);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const stacks: BusinessStackSummary[] = [];
|
||||
for (const slug of entries) {
|
||||
if (RESERVED_SLUGS.has(slug)) continue;
|
||||
const dir = path.join(root, slug);
|
||||
const composePath = path.join(dir, "compose.yaml");
|
||||
try {
|
||||
await access(composePath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const domain = await readDomain(path.join(dir, ".env"));
|
||||
const status = await composeStatus(dir);
|
||||
stacks.push({ slug, domain, containerName: `next-chair-${slug}`, status });
|
||||
}
|
||||
|
||||
return stacks.sort((a, b) => a.slug.localeCompare(b.slug));
|
||||
}
|
||||
|
||||
export async function stackExists(slug: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path.join(stackDir(slug), "compose.yaml"));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user