1import { extname, join, relative, resolve } from "node:path";2import { expectedPrecacheUrls, sourcePwaIssues } from "./pwa-validation.ts";34const ignoredDirectories = new Set([5 ".astro",6 ".git",7 ".lofi",8 ".playwright-cli",9 ".vite",10 "coverage",11 "node_modules",12 "playwright-report",13 "test-results",14]);1516export type ProjectCheck = {17 name: string;18 status: "ok" | "blocker";19 detail: string;20 remediation?: string;21};2223export async function projectChecks(24 root = Deno.cwd(),25 deploymentBase = "/",26): Promise<ProjectCheck[]> {27 // The generic local-first scaffold every lofi project carries, independent of28 // the app's domain. Islands, schema tables, and stores are author content and29 // are intentionally not required here — a project stays valid after the starter30 // example is replaced.31 const required = [32 "deno.json",33 "src/schema.ts",34 "src/permissions.ts",35 "src/app.ts",36 "src/pages/index.astro",37 "src/layouts/Shell.astro",38 "public/manifest.webmanifest",39 ];40 const missing: string[] = [];41 for (const path of required) {42 try {43 if (!(await Deno.stat(join(root, path))).isFile) missing.push(path);44 } catch (error) {45 if (error instanceof Deno.errors.NotFound) missing.push(path);46 else throw error;47 }48 }49 if (missing.length > 0) {50 return [{51 name: "Project",52 status: "blocker",53 detail: `missing ${missing.join(", ")}`,54 remediation: "restore the generated files or create a fresh project and move author files",55 }];56 }57 const pwaIssues = await sourcePwaIssues(root, deploymentBase);58 return [59 { name: "Project", status: "ok", detail: "generated layout complete" },60 ...(pwaIssues.length === 061 ? [{62 name: "PWA source",63 status: "ok" as const,64 detail: "manifest and assets are installable",65 }]66 : pwaIssues.map((pwaIssue) => ({67 name: "PWA source",68 status: "blocker" as const,69 detail: pwaIssue.detail,70 remediation: pwaIssue.remediation,71 }))),72 ];73}7475export async function walkFiles(76 root = Deno.cwd(),77 options: { includeDist?: boolean; extensions?: ReadonlySet<string> } = {},78): Promise<string[]> {79 const absoluteRoot = resolve(root);80 const files: string[] = [];81 async function visit(path: string) {82 const entries = [...Deno.readDirSync(path)].sort((left, right) =>83 left.name.localeCompare(right.name)84 );85 for (const entry of entries) {86 const child = join(path, entry.name);87 if (88 entry.name === ".env" || (entry.name.startsWith(".env.") && entry.name !== ".env.example")89 ) {90 continue;91 }92 if (entry.isDirectory) {93 if (ignoredDirectories.has(entry.name) && !(entry.name === "dist" && options.includeDist)) {94 continue;95 }96 await visit(join(path, entry.name));97 } else if (entry.isFile) {98 if (!options.extensions || options.extensions.has(extname(child))) {99 files.push(relative(absoluteRoot, child));100 }101 } else {102 throw new Error(103 `project contains unsupported symbolic link: ${relative(absoluteRoot, child)}; ` +104 "replace it with a regular file or directory, then rerun the command",105 );106 }107 }108 }109 await visit(absoluteRoot);110 return files.sort();111}112113export async function sourceFingerprint(root = Deno.cwd()): Promise<string> {114 const files = await walkFiles(root, {115 extensions: new Set([116 ".astro",117 ".css",118 ".js",119 ".json",120 ".png",121 ".svg",122 ".ts",123 ".tsx",124 ".webmanifest",125 ]),126 });127 const encoder = new TextEncoder();128 const chunks: Uint8Array[] = [];129 let length = 0;130 for (const path of files) {131 const header = encoder.encode(`${path}\0`);132 const content = await Deno.readFile(join(root, path));133 chunks.push(header, content);134 length += header.length + content.length;135 }136 const input = new Uint8Array(length);137 let offset = 0;138 for (const chunk of chunks) {139 input.set(chunk, offset);140 offset += chunk.length;141 }142 const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", input));143 return Array.from(digest).map((byte) => byte.toString(16).padStart(2, "0")).join("");144}145146export function precacheUrls(147 paths: readonly string[],148 presentationPaths: readonly string[] = [],149): string[] {150 return expectedPrecacheUrls(paths, presentationPaths);151}152153/**154 * Byte-identical groups among build-emitted assets (`_astro/`). The same155 * payload under two names means two build contexts disagreed on an asset156 * naming template, and every copy ships and precaches. User-authored files157 * outside `_astro/` are copied verbatim and are not the build's to judge.158 */159export async function duplicateBuildAssets(160 paths: readonly string[],161 root = "dist",162): Promise<string[][]> {163 const bySize = new Map<number, string[]>();164 for (const path of paths) {165 const portable = path.replaceAll("\\", "/");166 if (!portable.startsWith("_astro/")) continue;167 const { size } = await Deno.stat(join(root, path));168 bySize.set(size, [...(bySize.get(size) ?? []), portable]);169 }170 const groups: string[][] = [];171 for (const candidates of bySize.values()) {172 if (candidates.length < 2) continue;173 const byDigest = new Map<string, string[]>();174 for (const path of candidates) {175 const digest = new Uint8Array(176 await crypto.subtle.digest("SHA-256", await Deno.readFile(join(root, path))),177 );178 const key = Array.from(digest).map((byte) => byte.toString(16).padStart(2, "0")).join("");179 byDigest.set(key, [...(byDigest.get(key) ?? []), path]);180 }181 for (const group of byDigest.values()) {182 if (group.length > 1) groups.push(group.sort());183 }184 }185 return groups.sort((left, right) => left[0].localeCompare(right[0]));186}187188/** Total bytes a fresh install downloads for the app shell (the precached set). */189export async function shellWeightBytes(190 paths: readonly string[],191 root = "dist",192): Promise<number> {193 let total = 0;194 for (const path of paths) total += (await Deno.stat(join(root, path))).size;195 return total;196}197198function contains(haystack: Uint8Array, needle: Uint8Array): boolean {199 if (needle.length === 0 || haystack.length < needle.length) return false;200 outer:201 for (let offset = 0; offset <= haystack.length - needle.length; offset++) {202 for (let index = 0; index < needle.length; index++) {203 if (haystack[offset + index] !== needle[index]) continue outer;204 }205 return true;206 }207 return false;208}209210export async function scanSecrets(211 secrets: Readonly<Record<string, string>>,212 root = Deno.cwd(),213): Promise<{ scanned: number; leaks: Array<{ name: string; path: string }> }> {214 const values = Object.entries(secrets).filter(([, value]) => value.trim().length > 0);215 const files = await walkFiles(root, { includeDist: true });216 const encoder = new TextEncoder();217 const leaks: Array<{ name: string; path: string }> = [];218 for (const path of files) {219 const content = await Deno.readFile(join(root, path));220 for (const [name, value] of values) {221 if (contains(content, encoder.encode(value))) leaks.push({ name, path });222 }223 }224 return { scanned: files.length, leaks };225}