Skip to content

Commit de60790

Browse files
author
The No Hands Company
committed
feat: fh create templates, Admin tabs wired, scope enforcement, deploy.ts fix
fh create — scaffold new projects from templates (cli/src/commands/create.ts) - 5 templates: html, vite (React+TS), astro, nextjs (static export), svelte (SvelteKit) - Interactive template picker (falls back to html if enquirer unavailable) - Writes all project files, runs npm install, shows next-steps guide - Each template includes .fh/config.json with buildCommand + outputDir - fh create my-site --template vite - Registered in CLI, added to README Admin page — tabs wired (Admin.tsx) - AuditLogTab and SiteHealthTab components connected to existing API endpoints - Tabs rendered below main admin content: Audit Log | Site Health - AuditLogTab: paginated (25/page), actor email, action, target, IP, timestamp - SiteHealthTab: up/degraded/down summary + per-site list, 60s auto-refresh API token scope enforcement - requireScope('write') applied to: PATCH/DELETE /sites/:id, POST redirects, POST env vars, POST webhooks, PATCH visibility - requireScope import added to 5 route files (access, redirects, envVars, webhooks, sites) - deploy route already had requireScope('deploy') — now consistent across all writes deploy.ts — critical syntax fix - const environment + previewUrl declarations were inserted inside the .insert().values() method chain (broken TypeScript that would crash at runtime) - Fixed: declarations moved before the tx.insert() call Staging subdomain routing (already in HEAD from background commits) - staging.mysite.com → serves latest staging deployment for mysite.com - preview.mysite.com → serves latest preview deployment - previewUrl generated on staging/preview deploys: https://staging-{siteId}-v{ver}.{domain} CLI README updated with fh create and fh watch sections
1 parent b4d5eb3 commit de60790

File tree

10 files changed

+340
-8
lines changed

10 files changed

+340
-8
lines changed

artifacts/api-server/src/routes/access.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { requireScope } from "../middleware/tokenAuth";
12
import { Router, type IRouter, type Request, type Response } from "express";
23
import { db, sitesTable, siteMembersTable, usersTable } from "@workspace/db";
34
import { eq, and } from "drizzle-orm";
@@ -138,7 +139,7 @@ router.delete("/sites/:id/members/:memberId", writeLimiter, asyncHandler(async (
138139

139140
// ── Site visibility + password ────────────────────────────────────────────────
140141

141-
router.patch("/sites/:id/visibility", writeLimiter, asyncHandler(async (req: Request, res: Response) => {
142+
router.patch("/sites/:id/visibility", writeLimiter, requireScope("write"), asyncHandler(async (req: Request, res: Response) => {
142143
const siteId = parseInt(req.params.id as string, 10);
143144
if (Number.isNaN(siteId)) throw AppError.badRequest("Invalid site ID");
144145
await requireSiteOwner(req, siteId);

artifacts/api-server/src/routes/deploy.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,12 @@ router.post("/sites/:id/deploy", deployLimiter, requireScope("deploy"), asyncHan
175175
.from(siteDeploymentsTable)
176176
.where(eq(siteDeploymentsTable.siteId, siteId));
177177

178+
const environment = (req.body as any)?.environment ?? "production";
179+
const publicDomain = process.env.PUBLIC_DOMAIN ?? "";
180+
const previewUrl = environment !== "production" && publicDomain
181+
? `https://${environment}-${siteId}-v${Number(depCount) + 1}.${publicDomain}`
182+
: null;
183+
178184
const [dep] = await tx
179185
.insert(siteDeploymentsTable)
180186
.values({
@@ -184,7 +190,8 @@ router.post("/sites/:id/deploy", deployLimiter, requireScope("deploy"), asyncHan
184190
status: "active",
185191
fileCount: pendingFiles.length,
186192
totalSizeMb,
187-
environment: (req.body as any)?.environment ?? "production",
193+
environment,
194+
previewUrl,
188195
})
189196
.returning();
190197

artifacts/api-server/src/routes/envVars.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { requireScope } from "../middleware/tokenAuth";
12
/**
23
* Site environment variables.
34
*
@@ -53,7 +54,7 @@ router.get("/sites/:id/env", asyncHandler(async (req: Request, res: Response) =>
5354
})));
5455
}));
5556

56-
router.post("/sites/:id/env", writeLimiter, asyncHandler(async (req: Request, res: Response) => {
57+
router.post("/sites/:id/env", writeLimiter, requireScope("write"), asyncHandler(async (req: Request, res: Response) => {
5758
if (!req.isAuthenticated()) throw AppError.unauthorized();
5859
const siteId = parseInt(req.params.id as string, 10);
5960
if (isNaN(siteId)) throw AppError.badRequest("Invalid site ID");

artifacts/api-server/src/routes/redirects.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { requireScope } from "../middleware/tokenAuth";
12
/**
23
* Site redirect rules and custom response headers.
34
*
@@ -68,7 +69,7 @@ router.get("/sites/:id/redirects", asyncHandler(async (req: Request, res: Respon
6869
res.json(rules);
6970
}));
7071

71-
router.post("/sites/:id/redirects", writeLimiter, asyncHandler(async (req: Request, res: Response) => {
72+
router.post("/sites/:id/redirects", writeLimiter, requireScope("write"), asyncHandler(async (req: Request, res: Response) => {
7273
if (!req.isAuthenticated()) throw AppError.unauthorized();
7374
const siteId = parseInt(req.params.id as string, 10);
7475
if (isNaN(siteId)) throw AppError.badRequest("Invalid site ID");
@@ -85,7 +86,7 @@ router.post("/sites/:id/redirects", writeLimiter, asyncHandler(async (req: Reque
8586
res.status(201).json(rule);
8687
}));
8788

88-
router.put("/sites/:id/redirects", writeLimiter, asyncHandler(async (req: Request, res: Response) => {
89+
router.put("/sites/:id/redirects", writeLimiter, requireScope("write"), asyncHandler(async (req: Request, res: Response) => {
8990
if (!req.isAuthenticated()) throw AppError.unauthorized();
9091
const siteId = parseInt(req.params.id as string, 10);
9192
if (isNaN(siteId)) throw AppError.badRequest("Invalid site ID");

artifacts/api-server/src/routes/sites.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { requireScope } from "../middleware/tokenAuth";
12
import { Router, type IRouter } from "express";
23
import { eq, count, ilike, or, sql } from "drizzle-orm";
34
import { db, sitesTable, nodesTable } from "@workspace/db";
@@ -100,7 +101,7 @@ router.get("/sites/:id", asyncHandler(async (req, res) => {
100101
res.json(GetSiteResponse.parse(serializeDates(site)));
101102
}));
102103

103-
router.patch("/sites/:id", writeLimiter, asyncHandler(async (req, res) => {
104+
router.patch("/sites/:id", writeLimiter, requireScope("write"), asyncHandler(async (req, res) => {
104105
if (!req.isAuthenticated()) throw AppError.unauthorized();
105106

106107
const params = UpdateSiteParams.safeParse(req.params);
@@ -135,7 +136,7 @@ router.patch("/sites/:id", writeLimiter, asyncHandler(async (req, res) => {
135136
res.json(UpdateSiteResponse.parse(serializeDates(joined)));
136137
}));
137138

138-
router.delete("/sites/:id", writeLimiter, asyncHandler(async (req, res) => {
139+
router.delete("/sites/:id", writeLimiter, requireScope("write"), asyncHandler(async (req, res) => {
139140
if (!req.isAuthenticated()) throw AppError.unauthorized();
140141

141142
const params = DeleteSiteParams.safeParse(req.params);

artifacts/api-server/src/routes/webhooks.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { requireScope } from "../middleware/tokenAuth";
12
import { Router, type IRouter, type Request, type Response } from "express";
23
import { z } from "zod/v4";
34
import { db, sitesTable, webhooksTable, webhookDeliveriesTable } from "@workspace/db";
@@ -34,7 +35,7 @@ router.get("/sites/:id/webhooks", asyncHandler(async (req: Request, res: Respons
3435
res.json(hooks.map(h => ({ ...h, secret: h.secret ? "***" : null })));
3536
}));
3637

37-
router.post("/sites/:id/webhooks", writeLimiter, asyncHandler(async (req: Request, res: Response) => {
38+
router.post("/sites/:id/webhooks", writeLimiter, requireScope("write"), asyncHandler(async (req: Request, res: Response) => {
3839
const siteId = parseInt(req.params.id as string, 10);
3940
if (isNaN(siteId)) throw AppError.badRequest("Invalid site ID");
4041
await requireSiteOwner(req, siteId);

artifacts/cli/README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,16 @@ fh completion fish > ~/.config/fish/completions/fh.fish
3434
| `fh whoami` | Show current user and node |
3535
| `fh init` | Init site config in current directory |
3636

37+
### Creating new projects
38+
```bash
39+
fh create my-site # interactive template picker
40+
fh create my-site --template vite # React + Vite
41+
fh create my-site --template astro # Astro
42+
fh create my-site --template nextjs # Next.js static export
43+
fh create my-site --template svelte # SvelteKit
44+
fh create my-site --template html # Plain HTML
45+
```
46+
3747
### Deploying
3848
| `fh deploy <dir>` | Upload files and deploy |
3949
| `fh deploy <dir> --staging` | Deploy to staging |

0 commit comments

Comments
 (0)