Skip to content

Commit 374e457

Browse files
author
The No Hands Company
committed
feat: unlock message, personal dashboard, fh watch, deployment diff tests, fh status sites
Unlock message — full stack - sites.ts schema: unlockMessage field (already migrated via ALTER TABLE) - access.ts: PATCH /sites/:id/visibility now accepts unlockMessage field Stored alongside passwordHash when saving visibility - domainCache.ts: CachedSite interface gains unlockMessage: string | null - hostRouter.ts: unlockMessage read from DB and cache, passed to renderPasswordGate() Cache reconstruction includes unlockMessage - renderPasswordGate(): new optional message param injected as data-message attribute - password-gate.html: JS reads data-message and shows it instead of default text Falls back to '{domain} is password protected.' if no custom message - SiteSettings.tsx: new 'Custom message on password gate' input in visibility tab Inline Save button updates just the message without changing visibility/password Personal dashboard section - Dashboard.tsx: PersonalDashboard component appended below network stats Only rendered when isAuthenticated Fetches user's own sites (up to 5) with storageUsedMb and hitCount Each site card: name, domain, status badge, hits, storage, quick-action buttons Buttons: Deploy (→ /deploy/:id), Analytics, Builds, Settings Animated entry, links to /my-sites for full list fh watch — live file watching with auto-deploy - Watches a directory recursively for file changes - Debounced (default 800ms, configurable --delay) - Hash comparison prevents spurious deploys on unchanged files - Uploads only changed files, deploys to staging environment - Ctrl+C to stop - Usage: fh watch ./dist --site 42 fh status — shows user's sites and deployment versions - When authenticated (cfg.token set), fetches /sites?limit=5 - Shows each site: status dot, domain, storage - Shows active deployment: version, environment badge (if staging/preview), file count, date Deployment diff unit tests (tests/unit/deploymentDiff.test.ts) - 8 tests covering: empty→empty, first deploy, identical, updated file, removed file, all change types together, null hash (legacy rows), net size calculation - 8 tests for password gate unlock message injection including escaping, null/undefined
1 parent be2a565 commit 374e457

File tree

10 files changed

+484
-18
lines changed

10 files changed

+484
-18
lines changed

artifacts/api-server/src/lib/domainCache.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,12 @@
1818
*/
1919

2020
export interface CachedSite {
21-
siteId: number;
22-
domain: string;
23-
visibility: "public" | "private" | "password";
24-
passwordHash: string | null;
25-
cachedAt: number;
21+
siteId: number;
22+
domain: string;
23+
visibility: "public" | "private" | "password";
24+
passwordHash: string | null;
25+
unlockMessage: string | null;
26+
cachedAt: number;
2627
}
2728

2829
export interface CachedFile {

artifacts/api-server/src/middleware/hostRouter.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,10 @@ function verifyUnlockCookie(cookieValue: string | undefined, siteId: number): bo
6060
}
6161
}
6262

63-
function renderPasswordGate(siteId: number, domain: string): string {
63+
function renderPasswordGate(siteId: number, domain: string, message?: string | null): string {
6464
return getPasswordGateHtml().replace(
6565
"<body>",
66-
`<body data-site-id="${siteId}" data-domain="${domain.replace(/"/g, "&quot;")}">`
66+
`<body data-site-id="${siteId}" data-domain="${domain.replace(/"/g, "&quot;")}"${message ? ` data-message="${message.replace(/"/g, "&quot;")}"` : ""}>`
6767
);
6868
}
6969

@@ -192,7 +192,11 @@ export async function hostRouter(req: Request, res: Response, next: NextFunction
192192
const cached = getCachedSite(host);
193193
if (cached) {
194194
// Reconstruct minimal site object from cache
195-
site = { id: cached.siteId, domain: cached.domain, visibility: cached.visibility, passwordHash: cached.passwordHash } as typeof sitesTable.$inferSelect;
195+
site = {
196+
id: cached.siteId, domain: cached.domain,
197+
visibility: cached.visibility, passwordHash: cached.passwordHash,
198+
unlockMessage: cached.unlockMessage,
199+
} as typeof sitesTable.$inferSelect;
196200
} else {
197201
// Cache miss — query DB
198202
const [byPrimary] = await db.select().from(sitesTable).where(eq(sitesTable.domain, host));
@@ -216,6 +220,7 @@ export async function hostRouter(req: Request, res: Response, next: NextFunction
216220
domain: host,
217221
visibility: (site.visibility as "public" | "private" | "password") ?? "public",
218222
passwordHash: site.passwordHash ?? null,
223+
unlockMessage: (site as any).unlockMessage ?? null,
219224
});
220225
}
221226
}
@@ -228,7 +233,7 @@ export async function hostRouter(req: Request, res: Response, next: NextFunction
228233
}
229234

230235
if (site.visibility === "password" && !verifyUnlockCookie(req.cookies?.[`site_unlock_${site.id}`], site.id)) {
231-
res.status(401).send(renderPasswordGate(site.id, host));
236+
res.status(401).send(renderPasswordGate(site.id, host, (site as any).unlockMessage));
232237
return;
233238
}
234239

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,9 @@ const AddMemberBody = z.object({
2828
});
2929

3030
const UpdateVisibilityBody = z.object({
31-
visibility: z.enum(["public", "private", "password"]),
32-
password: z.string().min(6).max(128).optional(),
31+
visibility: z.enum(["public", "private", "password"]),
32+
password: z.string().min(6).max(128).optional(),
33+
unlockMessage: z.string().max(200).optional(), // custom message shown on password gate
3334
});
3435

3536
async function requireSiteOwner(req: Request, siteId: number) {
@@ -161,7 +162,7 @@ router.patch("/sites/:id/visibility", writeLimiter, asyncHandler(async (req: Req
161162

162163
const [updated] = await db
163164
.update(sitesTable)
164-
.set({ visibility, passwordHash })
165+
.set({ visibility, passwordHash, unlockMessage: parsed.data.unlockMessage ?? null })
165166
.where(eq(sitesTable.id, siteId))
166167
.returning({ id: sitesTable.id, visibility: sitesTable.visibility });
167168

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { describe, it, expect } from "vitest";
2+
3+
// ── Deployment diff logic ──────────────────────────────────────────────────────
4+
5+
interface FileEntry {
6+
filePath: string;
7+
contentHash: string | null;
8+
sizeBytes: number;
9+
}
10+
11+
function computeDiff(targetFiles: FileEntry[], baseFiles: FileEntry[]) {
12+
const targetMap = new Map(targetFiles.map(f => [f.filePath, f]));
13+
const baseMap = new Map(baseFiles.map(f => [f.filePath, f]));
14+
15+
const added: FileEntry[] = [];
16+
const changed: FileEntry[] = [];
17+
const unchanged: FileEntry[] = [];
18+
const removed: FileEntry[] = [];
19+
20+
for (const [path, file] of targetMap) {
21+
const base = baseMap.get(path);
22+
if (!base) {
23+
added.push(file);
24+
} else if (file.contentHash && base.contentHash && file.contentHash !== base.contentHash) {
25+
changed.push(file);
26+
} else {
27+
unchanged.push(file);
28+
}
29+
}
30+
31+
for (const [path, file] of baseMap) {
32+
if (!targetMap.has(path)) removed.push(file);
33+
}
34+
35+
return { added, changed, removed, unchanged };
36+
}
37+
38+
const f = (path: string, hash: string, size = 1024): FileEntry =>
39+
({ filePath: path, contentHash: hash, sizeBytes: size });
40+
41+
describe("Deployment diff logic", () => {
42+
it("empty → empty produces no changes", () => {
43+
const d = computeDiff([], []);
44+
expect(d.added).toHaveLength(0);
45+
expect(d.removed).toHaveLength(0);
46+
expect(d.changed).toHaveLength(0);
47+
expect(d.unchanged).toHaveLength(0);
48+
});
49+
50+
it("first deploy: all files are additions", () => {
51+
const target = [f("index.html", "hash1"), f("style.css", "hash2")];
52+
const d = computeDiff(target, []);
53+
expect(d.added).toHaveLength(2);
54+
expect(d.removed).toHaveLength(0);
55+
expect(d.changed).toHaveLength(0);
56+
});
57+
58+
it("identical deployments: all files are unchanged", () => {
59+
const files = [f("index.html", "hash1"), f("app.js", "hash2")];
60+
const d = computeDiff(files, files);
61+
expect(d.unchanged).toHaveLength(2);
62+
expect(d.added).toHaveLength(0);
63+
expect(d.removed).toHaveLength(0);
64+
expect(d.changed).toHaveLength(0);
65+
});
66+
67+
it("updated file detected via hash change", () => {
68+
const base = [f("index.html", "old-hash"), f("app.js", "hash2")];
69+
const target = [f("index.html", "new-hash"), f("app.js", "hash2")];
70+
const d = computeDiff(target, base);
71+
expect(d.changed).toHaveLength(1);
72+
expect(d.changed[0]!.filePath).toBe("index.html");
73+
expect(d.unchanged).toHaveLength(1);
74+
});
75+
76+
it("removed file detected", () => {
77+
const base = [f("index.html", "hash1"), f("old-page.html", "hash2")];
78+
const target = [f("index.html", "hash1")];
79+
const d = computeDiff(target, base);
80+
expect(d.removed).toHaveLength(1);
81+
expect(d.removed[0]!.filePath).toBe("old-page.html");
82+
expect(d.unchanged).toHaveLength(1);
83+
});
84+
85+
it("all change types in one diff", () => {
86+
const base = [
87+
f("index.html", "hash-a"),
88+
f("old.html", "hash-b"),
89+
f("style.css", "hash-c"),
90+
];
91+
const target = [
92+
f("index.html", "hash-a"), // unchanged
93+
f("style.css", "hash-c2"), // changed
94+
f("new-page.html","hash-d"), // added
95+
// old.html is removed
96+
];
97+
const d = computeDiff(target, base);
98+
expect(d.unchanged).toHaveLength(1);
99+
expect(d.unchanged[0]!.filePath).toBe("index.html");
100+
expect(d.changed).toHaveLength(1);
101+
expect(d.changed[0]!.filePath).toBe("style.css");
102+
expect(d.added).toHaveLength(1);
103+
expect(d.added[0]!.filePath).toBe("new-page.html");
104+
expect(d.removed).toHaveLength(1);
105+
expect(d.removed[0]!.filePath).toBe("old.html");
106+
});
107+
108+
it("null contentHash treats file as unchanged (legacy rows without hash)", () => {
109+
const base = [f("index.html", "hash1"), { filePath: "legacy.html", contentHash: null, sizeBytes: 500 }];
110+
const target = [f("index.html", "hash1"), { filePath: "legacy.html", contentHash: null, sizeBytes: 500 }];
111+
const d = computeDiff(target, base);
112+
expect(d.changed).toHaveLength(0);
113+
expect(d.unchanged).toHaveLength(2);
114+
});
115+
116+
it("summary net size bytes is correct", () => {
117+
const base = [f("old.html", "h1", 2000)];
118+
const target = [f("new.html", "h2", 3000)];
119+
const d = computeDiff(target, base);
120+
const netSize = d.added.reduce((a, f) => a + f.sizeBytes, 0)
121+
- d.removed.reduce((a, f) => a + f.sizeBytes, 0);
122+
expect(netSize).toBe(1000); // +3000 added, -2000 removed
123+
});
124+
});
125+
126+
// ── Unlock message injection ────────────────────────────────────────────────────
127+
128+
function renderPasswordGate(html: string, siteId: number, domain: string, message?: string | null): string {
129+
return html.replace(
130+
"<body>",
131+
`<body data-site-id="${siteId}" data-domain="${domain.replace(/"/g, "&quot;")}"${
132+
message ? ` data-message="${message.replace(/"/g, "&quot;")}"` : ""
133+
}>`
134+
);
135+
}
136+
137+
const GATE_HTML = '<html><body><p id="domain-label"></p></body></html>';
138+
139+
describe("Password gate unlock message injection", () => {
140+
it("injects data-site-id attribute", () => {
141+
const html = renderPasswordGate(GATE_HTML, 42, "example.com");
142+
expect(html).toContain('data-site-id="42"');
143+
});
144+
145+
it("injects data-domain attribute", () => {
146+
const html = renderPasswordGate(GATE_HTML, 1, "mysite.example.com");
147+
expect(html).toContain('data-domain="mysite.example.com"');
148+
});
149+
150+
it("injects data-message when provided", () => {
151+
const html = renderPasswordGate(GATE_HTML, 1, "example.com", "Members only — please log in");
152+
expect(html).toContain('data-message="Members only — please log in"');
153+
});
154+
155+
it("does not add data-message when message is null", () => {
156+
const html = renderPasswordGate(GATE_HTML, 1, "example.com", null);
157+
expect(html).not.toContain("data-message");
158+
});
159+
160+
it("does not add data-message when message is undefined", () => {
161+
const html = renderPasswordGate(GATE_HTML, 1, "example.com");
162+
expect(html).not.toContain("data-message");
163+
});
164+
165+
it("escapes double quotes in domain", () => {
166+
const html = renderPasswordGate(GATE_HTML, 1, 'site"with"quotes.com');
167+
expect(html).toContain('data-domain="site&quot;with&quot;quotes.com"');
168+
expect(html).not.toContain('data-domain="site"with"');
169+
});
170+
171+
it("escapes double quotes in message", () => {
172+
const html = renderPasswordGate(GATE_HTML, 1, "example.com", 'Say "hello" to enter');
173+
expect(html).toContain('data-message="Say &quot;hello&quot; to enter"');
174+
});
175+
176+
it("all attributes appear on body tag", () => {
177+
const html = renderPasswordGate(GATE_HTML, 99, "test.com", "Custom message");
178+
expect(html).toContain('<body data-site-id="99" data-domain="test.com" data-message="Custom message">');
179+
});
180+
});

artifacts/cli/src/commands/status.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,4 +129,32 @@ export const statusCommand = new Command("status")
129129
console.log(` ${chalk.dim("Uptime:")} ${capacity.uptimePercent.toFixed(1)}%`);
130130
console.log();
131131
}
132+
133+
// Show authenticated user's sites if logged in
134+
if (cfg.token) {
135+
try {
136+
const sites = await apiFetch<{ data: Array<{ id: number; name: string; domain: string; status: string; storageUsedMb: number }> }>(
137+
"/sites?limit=5"
138+
);
139+
if (sites.data?.length) {
140+
console.log(chalk.bold(" Your Sites"));
141+
for (const s of sites.data) {
142+
const icon = s.status === "active" ? chalk.green("●") : chalk.dim("○");
143+
console.log(` ${icon} ${chalk.white(s.domain.padEnd(35))} ${chalk.dim(`${s.storageUsedMb.toFixed(1)} MB`)}`);
144+
145+
try {
146+
const deps = await apiFetch<{ data: Array<{ version: number; environment: string; deployedAt: string; fileCount: number }> }>(
147+
`/sites/${s.id}/deployments?limit=1`
148+
);
149+
const d = deps.data?.[0];
150+
if (d) {
151+
const envLabel = d.environment && d.environment !== "production" ? chalk.yellow(` [${d.environment}]`) : "";
152+
console.log(` ${chalk.dim(`v${d.version}${envLabel} · ${d.fileCount} files · ${new Date(d.deployedAt).toLocaleDateString()}`)}`);
153+
}
154+
} catch { /* skip */ }
155+
}
156+
console.log();
157+
}
158+
} catch { /* skip if not authenticated */ }
159+
}
132160
});

0 commit comments

Comments
 (0)