- commit
- a120def02c295889f0595f0e649bb8433ea8bec7
- parent
- f561598c4516471b874320bc610408c9e928bc3a
- author
- Dami <dami.etoile@gmail.com>
| A | LICENSE | 21 | ++++ |
| M | README.md | 13 | ++- |
| M | apps/ | 11 | +- |
| M | deno.json | 5 | +- |
| M | docs/ | 16 | +- |
| M | jsr.json | 10 | ++- |
| A | package/ | 59 | +++++++++++ |
| A | package/ | 47 | ++++++++ |
| A | package/ | 33 | ++++++ |
| A | package/ | 7 | + |
| A | package/ | 53 | +++++++++ |
| A | package/ | 15 | +++ |
| A | package/ | 21 | ++++ |
| M | package/ | 21 | +++- |
| M | package/ | 63 | +++++++++++- |
| M | package/ | 4 | +- |
| A | package/ | 132 | ++++++++++++++++++++++++ |
| A | package/ | 82 | +++++++++++++++ |
| A | package/ | 168 | ++++++++++++++++++++++++++++++ |
| A | package/ | 41 | +++++++ |
| A | package/ | 140 | +++++++++++++++++++++++++ |
| A | package/ | 55 | ++++++++++ |
@@ -0,0 +1,21 @@
1+MIT License2+3+Copyright (c) 2026 lofi contributors4+5+Permission is hereby granted, free of charge, to any person obtaining a copy6+of this software and associated documentation files (the "Software"), to deal7+in the Software without restriction, including without limitation the rights8+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell9+copies of the Software, and to permit persons to whom the Software is10+furnished to do so, subject to the following conditions:11+12+The above copyright notice and this permission notice shall be included in all13+copies or substantial portions of the Software.14+15+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR16+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,17+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE18+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER19+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,20+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE21+SOFTWARE.@@ -40,9 +40,10 @@ previews, and opens the same stable secure origin on a physical phone — with D
4040 required global runtime and a command surface of exactly seven verbs: `create`, `dev`, `doctor`,4141 `check`, `test`, `build`, `preview`.4242 43-## Try it today (M1 journey)43+## Try it today (checkout journey)4444 45-The generator does not exist yet; the graduated prototype runs from a checkout:45+The M2 branch now contains the source generator and its package-owned commands, but the package is46+not published while Layer 2 is under review. The currently available journey runs from a checkout:4647 4748 ```sh4849 git clone https://github.com/FelineStateMachine/lofi.git@@ -67,7 +68,9 @@ deno task dev
6768 | `deno task build` | Production build through the supported Deno path. |6869 | `deno task preview` | Serves the production build. |6970 70-`create` and `doctor` graduate in M2 — their output contracts are already specified in the71+`create` and `doctor` are implemented in the M2 source package. They graduate only after the72+generated-project golden journey, clean-room review, and registry-backed smoke pass; their output73+contracts are specified in the7174 [DevX contract](docs/devx-contract.md#canonical-command-surface-and-output-contract).7275 7376 ## How this repository works@@ -109,10 +112,12 @@ lofi/
109112 │ ├── devx-contract.md # The product contract: promises, budgets, statuses110113 │ └── decisions/ # Evidence-backed decision records (ADR-style)111114 ├── apps/reference/ # The integrated app graduated from M1 and reshaped in M2115+├── package/ # The single @nzip/lofi package source and generated-project commands112116 ├── spikes/ # M1 feasibility experiments, kept for evidence113117 ├── tools/ # Deno tasks: env contract, secret scanning, runners114118 ├── init.md # Original research plan — input, not the contract115-└── deno.json # Workspace tasks and pinned toolchain119+├── deno.json # Workspace tasks and pinned toolchain120+└── jsr.json # One package name, version, export map, and publish manifest116121 ```117122 118123 ## Stack@@ -1,7 +1,8 @@
11 # lofi reference application22 3-This application proves the author-facing lofi workflow before the public `@nzip/lofi` package is4-extracted. It is deliberately split into two ownership zones.3+This application proves the author-facing lofi workflow while the public `@nzip/lofi` package is4+graduated in layers. M2 extracts the generated-project commands; broader framework API extraction5+remains M4 work. The application is deliberately split into two ownership zones.56 67 ## Author-owned files78 @@ -19,9 +20,9 @@ extracted. It is deliberately split into two ownership zones.
1920 - `public/manifest.webmanifest`, `public/sw.js`, and `public/favicon.svg` — PWA plumbing2021 - `src/_lofi/` — storage, identity, synchronization, lifecycle, diagnostics, and framework adapter2122 22-Application work should not require edits in `src/_lofi/`. Its local path is a temporary extraction23-seam: later M2 work may replace it with generated files or `@nzip/lofi` subpath imports without24-changing product UI.23+Application work should not require edits in `src/_lofi/`. The M2 generator copies these validated24+runtime-owned files without turning them into public framework APIs. M4 may replace the local seam25+with proven `@nzip/lofi` subpath imports without changing product UI.2526 2627 Passkey backup and recovery are intentionally absent. The pinned Jazz alpha exposes a rejected2728 credential design, so this reference uses a device-local identity and does not imply recoverability.@@ -25,8 +25,9 @@
2525 "check:secrets": "deno run --allow-env=JAZZ_ADMIN_SECRET,BACKEND_SECRET --allow-read --allow-run=git tools/check_secret_leaks.ts",2626 "check:migrations": "deno run --allow-read=apps/reference/src/migrations --allow-run tools/check_migrations.ts",2727 "check:astro": "LOFI_SKIP_JAZZ_MANAGED=1 JAZZ_ADMIN_SECRET= BACKEND_SECRET= deno run -A npm:astro@7.0.9 check --root apps/reference",28- "check": "deno fmt --check && deno lint && deno check tools/*.ts spikes/jazz2-baseline/*.ts spikes/jazz2-baseline/src/*.ts apps/reference/*.ts apps/reference/src/**/*.ts apps/reference/src/**/*.tsx && deno task check:astro && deno task check:env && deno task check:secrets && deno task check:migrations && deno task test",29- "test:unit": "deno test --allow-net=127.0.0.1 --allow-read --allow-write=. --allow-run tools package apps/reference/src/_lofi apps/reference/tests",28+ "check": "deno fmt --check && deno lint && deno check package/**/*.ts tools/*.ts spikes/jazz2-baseline/*.ts spikes/jazz2-baseline/src/*.ts apps/reference/*.ts apps/reference/src/**/*.ts apps/reference/src/**/*.tsx && deno task check:astro && deno task check:env && deno task check:secrets && deno task check:migrations && deno task test",29+ "test:unit": "deno test --allow-env=LOFI_UPDATE_SNAPSHOT --allow-net=127.0.0.1 --allow-read --allow-write=. --allow-run tools package apps/reference/src/_lofi apps/reference/tests",30+ "test:update:create": "LOFI_UPDATE_SNAPSHOT=1 deno test --allow-env=LOFI_UPDATE_SNAPSHOT --allow-read --allow-write=. --allow-run package/create_core_test.ts",3031 "test": "deno task test:unit",3132 "test:browser:install": "deno run -A npm:playwright@1.61.1 install chromium",3233 "test:golden:checkout": "deno run -A tools/golden_path_checkout.ts",@@ -6,14 +6,14 @@ Last reviewed: 2026-07-15
66 This ledger keeps the Layer 1 reference application honest. A listed leak is not a supported lofi77 API; it is a concrete input to the layer that owns removing or productizing it.88 9-| Leak retained in Layer 1 | Why it remains | Owning issue | Graduation evidence |10-| -------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------- |11-| Repository-root wrappers own `dev`, `build`, preview, environment loading, and secret scans. | Extracting commands before the reference journey is executable would freeze an untested project shape. | #13, #14 | A generated directory runs its own documented tasks without repository-relative paths. |12-| `schema.ts` and `permissions.ts` import the pinned Jazz schema DSL directly. | M1 rejected inventing a second schema language without a proven ergonomic gain. | Later API extraction after M2 | A second real application demonstrates a smaller stable facade, or the direct DSL is accepted explicitly. |13-| `app.ts` uses a provisional plain configuration object and the Jazz schema value. | Layer 1 needs configuration-as-data but does not yet have two consumers that justify a public factory or exported type. | #13 | The generator emits the approved configuration without raw client, transport, or environment plumbing. |14-| Runtime cardinality and lifecycle evidence use a development-only global probe. | Product UI must stay clean while the browser journey still needs deterministic evidence. | #10 | The inspector exposes the same truthful signals through a supported development surface and is absent from production. |15-| The checkout golden path owns process/browser orchestration at repository scope. | Layer 1 proves the journey before defining a reusable testing package. | #12 | Offline and multi-client helpers are callable from generated-project tests with readiness-based APIs. |16-| Cloud/global settlement is observable per write, but live socket state is not. | Jazz alpha.53 exposes mutation durability but no supported connection-state signal. | #10, #12 | Inspector/tests distinguish configured mode, local durability, global durability, and unavailable live detail. |9+| Leak retained in Layer 1 | Why it remains | Owning issue | Graduation evidence |10+| ----------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------- |11+| Repository-root wrappers still own the checkout journey; generated commands now live in `@nzip/lofi`. | The generated path is implemented without repository-relative paths, but this leak remains listed until exact-head generated golden evidence is retained. | #13, #14 | A generated directory runs its own documented tasks without repository-relative paths. |12+| `schema.ts` and `permissions.ts` import the pinned Jazz schema DSL directly. | M1 rejected inventing a second schema language without a proven ergonomic gain. | Later API extraction after M2 | A second real application demonstrates a smaller stable facade, or the direct DSL is accepted explicitly. |13+| `app.ts` uses a provisional plain configuration object and the Jazz schema value. | Layer 1 needs configuration-as-data but does not yet have two consumers that justify a public factory or exported type. | #13 | The generator emits the approved configuration without raw client, transport, or environment plumbing. |14+| Runtime cardinality and lifecycle evidence use a development-only global probe. | Product UI must stay clean while the browser journey still needs deterministic evidence. | #10 | The inspector exposes the same truthful signals through a supported development surface and is absent from production. |15+| The checkout golden path owns process/browser orchestration at repository scope. | Layer 1 proves the journey before defining a reusable testing package. | #12 | Offline and multi-client helpers are callable from generated-project tests with readiness-based APIs. |16+| Cloud/global settlement is observable per write, but live socket state is not. | Jazz alpha.53 exposes mutation durability but no supported connection-state signal. | #10, #12 | Inspector/tests distinguish configured mode, local durability, global durability, and unavailable live detail. |1717 1818 Deleting an entry requires retained evidence in the owning issue or a decision record accepting the1919 behavior as an intentional public boundary.@@ -2,11 +2,19 @@
22 "$schema": "https://jsr.io/schema/config-file.v1.json",33 "name": "@nzip/lofi",44 "version": "0.1.0",5+ "license": "MIT",56 "exports": {6- "./create": "./package/create.ts"7+ "./build": "./package/commands/build.ts",8+ "./check": "./package/commands/check.ts",9+ "./create": "./package/create.ts",10+ "./dev": "./package/commands/dev.ts",11+ "./doctor": "./package/commands/doctor.ts",12+ "./preview": "./package/commands/preview.ts",13+ "./test": "./package/commands/run_tests.ts"714 },815 "publish": {916 "include": [17+ "LICENSE",1018 "README.md",1119 "apps/reference/astro.config.ts",1220 "apps/reference/public",@@ -0,0 +1,59 @@
1+2+3+import { extname, join, normalize } from "node:path";4+import { LOFI_VERSION } from "../version.ts";5+import { loadEnvironment, serverEnvironmentNames } from "../tooling/environment.ts";6+import { scanSecrets, sourceFingerprint, walkFiles } from "../tooling/project.ts";7+import { runDeno } from "../tooling/process.ts";8+import { exitOnFailure, validatedCommandEnvironment } from "./shared.ts";9+10+const environment = await validatedCommandEnvironment();11+environment.LOFI_SKIP_JAZZ_MANAGED = "1";12+environment.JAZZ_ADMIN_SECRET = "";13+environment.BACKEND_SECRET = "";14+const sourceHash = (await sourceFingerprint()).slice(0, 12);15+16+exitOnFailure(17+ await runDeno(["run", "-A", "npm:astro@7.0.9", "build"], environment),18+ "production build",19+);20+21+await Deno.writeTextFile(22+ "dist/lofi-build.json",23+ `${24+ JSON.stringify({ lofiVersion: LOFI_VERSION, sourceHash, builtAt: new Date().toISOString() })25+ }\n`,26+);27+const serviceWorkerPath = "dist/sw.js";28+const serviceWorker = await Deno.readTextFile(serviceWorkerPath);29+await Deno.writeTextFile(30+ serviceWorkerPath,31+ serviceWorker.replace("__LOFI_BUILD_REVISION__", sourceHash),32+);33+const precache = (await walkFiles("dist", { includeDist: true }))34+ .map((path) => `./${normalize(path)}`)35+ .filter((path) => path !== "./lofi-precache.json")36+ .map((path) => path === "./index.html" ? "./" : path);37+precache.push("./lofi-precache.json");38+await Deno.writeTextFile("dist/lofi-precache.json", `${JSON.stringify(precache.sort())}\n`);39+40+const loadedEnvironment = await loadEnvironment();41+const serverSecrets = Object.fromEntries(42+ serverEnvironmentNames.map((name) => [name, loadedEnvironment[name] ?? ""]),43+);44+const secretResult = await scanSecrets(serverSecrets);45+if (secretResult.leaks.length > 0) {46+ for (const leak of secretResult.leaks) {47+ console.error(`error: ${leak.name} value found in ${leak.path}`);48+ }49+ console.error(50+ "error: build output may expose a server secret; remove it and rerun `deno task build`",51+ );52+ Deno.exit(1);53+}54+55+const routes = (await walkFiles("dist", { includeDist: true }))56+ .filter((path) => extname(path) === ".html").length;57+console.log(58+ `lofi build: ${join(Deno.cwd(), "dist")} (${routes} routes, ${sourceHash}, secret scan passed)`,59+);@@ -0,0 +1,47 @@
1+2+3+import {4+ childEnvironment,5+ loadEnvironment,6+ serverEnvironmentNames,7+} from "../tooling/environment.ts";8+import { doctorReport, printDoctorReport } from "../tooling/diagnostics.ts";9+import { scanSecrets, walkFiles } from "../tooling/project.ts";10+import { runDeno } from "../tooling/process.ts";11+import { exitOnFailure } from "./shared.ts";12+13+const report = await doctorReport();14+if (report.blocked || !report.validation.ok) {15+ printDoctorReport(report);16+ console.error("error: check stopped at lofi preflight; rerun `deno task check` after the action");17+ Deno.exit(1);18+}19+const environment = childEnvironment(report.validation);20+environment.LOFI_SKIP_JAZZ_MANAGED = "1";21+const typedFiles = await walkFiles(Deno.cwd(), {22+ extensions: new Set([".ts", ".tsx"]),23+});24+25+for (26+ const [name, args] of [27+ ["format", ["fmt", "--check"]],28+ ["lint", ["lint"]],29+ ["type", ["check", ...typedFiles]],30+ ["Astro", ["run", "-A", "npm:astro@7.0.9", "check"]],31+ ] as const32+) {33+ exitOnFailure(await runDeno(args, environment), `${name} check`);34+}35+36+const loadedEnvironment = await loadEnvironment();37+const secrets = await scanSecrets(38+ Object.fromEntries(serverEnvironmentNames.map((name) => [name, loadedEnvironment[name] ?? ""])),39+);40+if (secrets.leaks.length > 0) {41+ for (const leak of secrets.leaks) {42+ console.error(`error: ${leak.name} value found in ${leak.path}`);43+ }44+ console.error("error: secret scan failed; remove the value and rerun `deno task check`");45+ Deno.exit(1);46+}47+console.log(`lofi check passed (4 checks, ${secrets.scanned} files secret-scanned)`);@@ -0,0 +1,33 @@
1+2+3+import { runDeno } from "../tooling/process.ts";4+import { validatedCommandEnvironment } from "./shared.ts";5+6+const environment = await validatedCommandEnvironment();7+environment.ASTRO_DEV_BACKGROUND = "1";8+9+console.log("lofi dev");10+console.log("Storage: OPFS durable requested; browser gate required");11+console.log("Persistence: pending browser check");12+console.log("Identity: device-local key; passkey backup blocked by alpha security review");13+console.log(14+ `Sync: ${15+ environment.JAZZ_APP_ID16+ ? "managed configured; live connection detail unavailable"17+ : "local-only"18+ }`,19+);20+console.log("PWA: development service worker disabled");21+console.log("Device: stable HTTPS URL not configured; device preview graduates in M3");22+23+const forwarded = Deno.args[0] === "--" ? Deno.args.slice(1) : Deno.args;24+const exitCode = await runDeno(25+ ["run", "-A", "npm:astro@7.0.9", "dev", ...forwarded],26+ environment,27+);28+if (exitCode !== 0) {29+ console.error(30+ "error: development server stopped; application impact: no local URL is available. Run `deno task doctor`, apply the first action, then rerun `deno task dev`.",31+ );32+ Deno.exit(exitCode);33+}@@ -0,0 +1,53 @@
1+2+3+import { extname, join, normalize } from "node:path";4+5+const portFlag = Deno.args.findIndex((argument) => argument === "--port");6+const port = portFlag >= 0 ? Number(Deno.args[portFlag + 1]) : 4321;7+if (!Number.isInteger(port) || port < 1 || port > 65_535) {8+ console.error("error: preview --port must be an integer from 1 to 65535");9+ Deno.exit(2);10+}11+12+let identity: { lofiVersion?: string; sourceHash?: string };13+try {14+ identity = JSON.parse(await Deno.readTextFile("dist/lofi-build.json"));15+} catch {16+ console.error("error: production build is missing or stale; run `deno task build` first");17+ Deno.exit(1);18+}19+if (!identity.sourceHash) {20+ console.error("error: production build identity is invalid; run `deno task build` again");21+ Deno.exit(1);22+}23+24+const contentTypes: Record<string, string> = {25+ ".css": "text/css; charset=utf-8",26+ ".html": "text/html; charset=utf-8",27+ ".js": "text/javascript; charset=utf-8",28+ ".json": "application/json; charset=utf-8",29+ ".svg": "image/svg+xml",30+ ".wasm": "application/wasm",31+ ".webmanifest": "application/manifest+json",32+};33+34+console.log(35+ `lofi preview: http://127.0.0.1:${port}/ (build ${identity.sourceHash}, @nzip/lofi ${identity.lofiVersion})`,36+);37+Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {38+ const url = new URL(request.url);39+ let pathname = decodeURIComponent(url.pathname);40+ if (pathname.endsWith("/")) pathname += "index.html";41+ const relative = normalize(pathname).replace(/^[/\\]+/, "");42+ if (relative.startsWith("..")) return new Response("Not found", { status: 404 });43+ const path = join("dist", relative);44+ try {45+ const body = await Deno.readFile(path);46+ return new Response(request.method === "HEAD" ? null : body, {47+ headers: { "content-type": contentTypes[extname(path)] ?? "application/octet-stream" },48+ });49+ } catch (error) {50+ if (error instanceof Deno.errors.NotFound) return new Response("Not found", { status: 404 });51+ throw error;52+ }53+});@@ -0,0 +1,15 @@
1+2+3+import { runDeno } from "../tooling/process.ts";4+import { exitOnFailure, validatedCommandEnvironment } from "./shared.ts";5+6+const environment = await validatedCommandEnvironment();7+const started = performance.now();8+const exitCode = await runDeno(9+ ["test", "--allow-read", "--allow-write=.", "src/_lofi", "tests"],10+ environment,11+);12+exitOnFailure(exitCode, "local-first test suite; retained browser artifacts use test-results/");13+console.log(14+ `lofi test passed (${Math.round(performance.now() - started)}ms; artifacts: test-results/)`,15+);@@ -0,0 +1,21 @@
1+import { doctorReport, printDoctorReport } from "../tooling/diagnostics.ts";2+import { childEnvironment } from "../tooling/environment.ts";3+4+export async function validatedCommandEnvironment(): Promise<Record<string, string>> {5+ const report = await doctorReport();6+ if (report.blocked || !report.validation.ok) {7+ printDoctorReport(report);8+ console.error(9+ "error: lofi preflight found blockers; apply the printed action and rerun the command",10+ );11+ Deno.exit(1);12+ }13+ for (const warning of report.validation.warnings) console.warn(`warning: ${warning}`);14+ return childEnvironment(report.validation);15+}16+17+export function exitOnFailure(exitCode: number, action: string): void {18+ if (exitCode === 0) return;19+ console.error(`error: ${action} failed; fix the first error above and rerun the same Deno task`);20+ Deno.exit(exitCode);21+}@@ -50,19 +50,29 @@ deno task dev
5050 \`\`\`5151 5252 The default project runs local-only with durable browser storage. To enable managed sync, copy53-\`.env.example\` to \`.env\` and set both public Jazz values. Run \`deno task doctor\` before boot54-when you want configuration diagnostics without starting the application.53+\`.env.example\` to \`.env\` and set both public Jazz values. Run \`deno task doctor\` before boot when54+you want configuration diagnostics without starting the application.5555 5656 Public tasks: \`dev\`, \`doctor\`, \`check\`, \`test\`, \`build\`, and \`preview\`.5757 `;5858 }5959 6060 function generatedDenoConfig(packagePrefix: string): string {61+ const packageCommand = (subpath: string, localPath: string) =>62+ packagePrefix.startsWith("file:")63+ ? `${packagePrefix}${localPath}`64+ : `${packagePrefix}${subpath}`;6165 const config = {6266 imports: {6367 "@astrojs/check": "npm:@astrojs/check@0.9.9",6468 "@astrojs/preact": "npm:@astrojs/preact@6.0.1",6569 "@nzip/lofi/": packagePrefix,70+ "@nzip/lofi/build": packageCommand("build", "commands/build.ts"),71+ "@nzip/lofi/check": packageCommand("check", "commands/check.ts"),72+ "@nzip/lofi/dev": packageCommand("dev", "commands/dev.ts"),73+ "@nzip/lofi/doctor": packageCommand("doctor", "commands/doctor.ts"),74+ "@nzip/lofi/preview": packageCommand("preview", "commands/preview.ts"),75+ "@nzip/lofi/test": packageCommand("test", "commands/run_tests.ts"),6676 "astro": "npm:astro@7.0.9",6777 "astro/config": "npm:astro@7.0.9/config",6878 "jazz-tools": "npm:jazz-tools@2.0.0-alpha.53",@@ -83,9 +93,8 @@ function generatedDenoConfig(packagePrefix: string): string {
8393 tasks: {8494 dev: "deno run -A @nzip/lofi/dev",8595 doctor: "deno run -A @nzip/lofi/doctor",86- check:87- "deno fmt --check && deno lint && deno check astro.config.ts src/**/*.ts src/**/*.tsx && deno run -A npm:astro@7.0.9 check",88- test: "deno test --allow-read --allow-write=. src/_lofi tests",96+ check: "deno run -A @nzip/lofi/check",97+ test: "deno run -A @nzip/lofi/test",8998 build: "deno run -A @nzip/lofi/build",9099 preview: "deno run -A @nzip/lofi/preview",91100 },@@ -208,7 +217,7 @@ export async function createProject(options: CreateProjectOptions): Promise<Crea
208217 generatedDenoConfig(options.packagePrefix ?? LOFI_PACKAGE_PREFIX),209218 );210219 211- await Deno.remove(destination, { recursive: true }).catch((error) => {220+ await Deno.remove(destination).catch((error) => {212221 if (!(error instanceof Deno.errors.NotFound)) throw error;213222 });214223 await Deno.rename(staging, destination);@@ -1,5 +1,7 @@
11 import { join } from "node:path";2+import packageManifest from "../jsr.json" with { type: "json" };23 import { createProject } from "./create_core.ts";4+import { LOFI_VERSION } from "./version.ts";35 46 function assert(condition: unknown, message: string): asserts condition {57 if (!condition) throw new Error(message);@@ -60,6 +62,15 @@ async function fileManifest(root: string): Promise<Record<string, string>> {
6062 return manifest;6163 }6264 65+function snapshotDelta(66+ actual: Readonly<Record<string, string>>,67+ expected: Readonly<Record<string, string>>,68+): string[] {69+ return [...new Set([...Object.keys(actual), ...Object.keys(expected)])].sort().filter((path) =>70+ actual[path] !== expected[path]71+ );72+}73+6374 function makeTestRoot(): Promise<string> {6475 return Deno.makeTempDir({ dir: ".", prefix: ".lofi-create-test-" });6576 }@@ -73,14 +84,43 @@ Deno.test("createProject materializes the complete starter snapshot", async () =
7384 const expected = JSON.parse(7485 await Deno.readTextFile(new URL("./testdata/starter.snapshot.json", import.meta.url)),7586 );76- assertEquals(actual, expected);87+ if (Deno.env.get("LOFI_UPDATE_SNAPSHOT") === "1") {88+ await Deno.writeTextFile(89+ new URL("./testdata/starter.snapshot.json", import.meta.url),90+ `${JSON.stringify(actual, null, 2)}\n`,91+ );92+ } else {93+ const changed = snapshotDelta(actual, expected);94+ assert(95+ changed.length === 0,96+ `generated starter snapshot changed: ${97+ changed.join(", ")98+ }. Review the generated files, then run \`deno task test:update:create\` to accept them.`,99+ );100+ }77101 const config = JSON.parse(await Deno.readTextFile(join(result.destination, "deno.json")));78102 assertEquals(config.imports["@nzip/lofi/"], "jsr:@nzip/lofi@0.1.0/");103+ const lofiSpecifiers = Object.entries(config.imports)104+ .filter(([name]) => name.startsWith("@nzip/lofi/"))105+ .map(([, specifier]) => String(specifier));106+ assert(107+ lofiSpecifiers.length === 7,108+ `expected one package prefix and six command mappings, received ${lofiSpecifiers.length}`,109+ );110+ assert(111+ lofiSpecifiers.every((specifier) => specifier.startsWith("jsr:@nzip/lofi@0.1.0/")),112+ "generated lofi commands do not resolve through one exact package version",113+ );79114 } finally {80115 await Deno.remove(cwd, { recursive: true });81116 }82117 });83118 119+Deno.test("package manifest and generated version stay coupled", () => {120+ assertEquals(packageManifest.name, "@nzip/lofi");121+ assertEquals(packageManifest.version, LOFI_VERSION);122+});123+84124 Deno.test("createProject refuses a non-empty destination without changing it", async () => {85125 const cwd = await makeTestRoot();86126 const destination = join(cwd, "taken");@@ -96,6 +136,27 @@ Deno.test("createProject refuses a non-empty destination without changing it", a
96136 }97137 });98138 139+Deno.test("createProject refuses path traversal without creating an outside directory", async () => {140+ const cwd = await makeTestRoot();141+ const outside = join(cwd, "..", "outside-from-lofi-create-test");142+ try {143+ await Deno.remove(outside, { recursive: true }).catch(() => undefined);144+ await assertRejects(145+ () => createProject({ cwd, name: "../outside-from-lofi-create-test" }),146+ "'..'",147+ );148+ try {149+ await Deno.stat(outside);150+ throw new Error("path traversal created an outside destination");151+ } catch (error) {152+ assert(error instanceof Deno.errors.NotFound, "unexpected outside traversal path state");153+ }154+ } finally {155+ await Deno.remove(cwd, { recursive: true });156+ await Deno.remove(outside, { recursive: true }).catch(() => undefined);157+ }158+});159+99160 Deno.test("createProject accepts an existing empty directory and a local package override", async () => {100161 const cwd = await makeTestRoot();101162 try {@@ -1,9 +1,9 @@
11 {22 ".env.example": "c22aed73c7089168ac777fd4f68ad504f9b826ea7185b705514a4ac5505918b1",33 ".gitignore": "9efb15c6a9bfc20f296ed712e74273b6015a0dd9a40f7e861b8711dcf61825f1",4- "README.md": "ceb7af0a5d78b71c85ecbfdf3d93bb8f46db7bbd4879eba844884164078dcf2d",4+ "README.md": "26fb40f86d03a403519bbce0f263b2305971b98c310a19cec37ab0079130fb0c",55 "astro.config.ts": "8a19cae348af7fcbd9db119d2423dd2e464f0e5ace6e5723d06cd3a1cc9a0d4b",6- "deno.json": "99649631e408331b1d18c15e28c44219055afe52ffc645e0d9b3a9248ca1d868",6+ "deno.json": "11183a356cb65dc17394945622fffd16828c5f47fb88388451b61850c620d3f8",77 "public/favicon.svg": "23efdb888dae688715faafe3ef94f7b5ad1976afcc715c0a742218b54732c6ad",88 "public/manifest.webmanifest": "d23457fe8df68a16181fde1d4ecca4b69d3b050b5fa3bf8f4051cb05bde1d3d6",99 "public/sw.js": "d816161fd5a35f2c477ca72eaa5ba58eb8d94795a4399d29a19838e886dafa31",@@ -0,0 +1,132 @@
1+import { LOFI_VERSION } from "../version.ts";2+import { type EnvironmentValidation, loadEnvironment, validateEnvironment } from "./environment.ts";3+import { type ProjectCheck, projectChecks } from "./project.ts";4+5+export type Diagnostic = {6+ name: string;7+ status: "ok" | "pending" | "blocker";8+ detail: string;9+ remediation?: string;10+};11+12+export type DoctorReport = {13+ version: string;14+ validation: EnvironmentValidation;15+ diagnostics: Diagnostic[];16+ blocked: boolean;17+};18+19+function denoSupported(version: string): boolean {20+ const [major = 0, minor = 0] = version.split(".").map(Number);21+ return major > 2 || (major === 2 && minor >= 9);22+}23+24+function projectDiagnostics(checks: readonly ProjectCheck[]): Diagnostic[] {25+ return checks.map((check) => ({26+ name: check.name,27+ status: check.status,28+ detail: check.detail,29+ remediation: check.remediation,30+ }));31+}32+33+export async function doctorReport(34+ options: {35+ root?: string;36+ environment?: Readonly<Record<string, string>>;37+ denoVersion?: string;38+ } = {},39+): Promise<DoctorReport> {40+ const root = options.root ?? Deno.cwd();41+ const environment = options.environment ?? await loadEnvironment(`${root}/.env`);42+ const validation = validateEnvironment(environment);43+ const denoVersion = options.denoVersion ?? Deno.version.deno;44+ const diagnostics: Diagnostic[] = [45+ {46+ name: "Package",47+ status: "ok",48+ detail: `@nzip/lofi ${LOFI_VERSION}`,49+ },50+ denoSupported(denoVersion) ? { name: "Deno", status: "ok", detail: denoVersion } : {51+ name: "Deno",52+ status: "blocker",53+ detail: `${denoVersion} is older than the supported 2.9 line`,54+ remediation: "upgrade Deno, then rerun `deno task doctor`",55+ },56+ ...projectDiagnostics(await projectChecks(root)),57+ ];58+59+ if (validation.ok) {60+ diagnostics.push({61+ name: "Environment",62+ status: "ok",63+ detail: validation.mode,64+ });65+ } else {66+ for (const error of validation.errors) {67+ diagnostics.push({68+ name: "Environment",69+ status: "blocker",70+ detail: error,71+ remediation: "edit `.env` once, then rerun `deno task doctor`",72+ });73+ }74+ }75+ diagnostics.push(76+ {77+ name: "Storage",78+ status: "pending",79+ detail: "OPFS durable driver requested; browser capability check runs at application boot",80+ remediation: "open the printed local or stable HTTPS URL in a supported browser",81+ },82+ {83+ name: "Persistence",84+ status: "pending",85+ detail: "browser eviction protection has not been queried",86+ remediation: "use the in-app device gate to request and verify persistence",87+ },88+ {89+ name: "Identity",90+ status: "ok",91+ detail: "device-local key; passkey backup blocked by alpha security review",92+ },93+ {94+ name: "Sync",95+ status: validation.mode === "cloud-configured" ? "ok" : "pending",96+ detail: validation.mode === "cloud-configured"97+ ? "managed adapter configured; live connection detail unavailable"98+ : "local-only; no transport convergence claim",99+ },100+ {101+ name: "PWA",102+ status: "pending",103+ detail: "development service worker disabled; production activation checked in browser",104+ },105+ {106+ name: "Device URL",107+ status: "pending",108+ detail: "not configured; stable HTTPS device preview graduates in M3",109+ },110+ );111+112+ return {113+ version: LOFI_VERSION,114+ validation,115+ diagnostics,116+ blocked: diagnostics.some((diagnostic) => diagnostic.status === "blocker"),117+ };118+}119+120+export function printDoctorReport(report: DoctorReport): void {121+ console.log(`lofi doctor ${report.version}`);122+ const width = Math.max(...report.diagnostics.map((diagnostic) => diagnostic.name.length));123+ for (const diagnostic of report.diagnostics) {124+ console.log(125+ `${diagnostic.name.padEnd(width)} ${diagnostic.status.padEnd(7)} ${diagnostic.detail}`,126+ );127+ if (diagnostic.remediation) {128+ console.log(`${"".padEnd(width)} action ${diagnostic.remediation}`);129+ }130+ }131+ for (const warning of report.validation.warnings) console.warn(`warning: ${warning}`);132+}@@ -0,0 +1,82 @@
1+import { join } from "node:path";2+import { createProject } from "../create_core.ts";3+import { doctorReport } from "./diagnostics.ts";4+5+function assert(condition: unknown, message: string): asserts condition {6+ if (!condition) throw new Error(message);7+}8+9+function makeTestRoot(): Promise<string> {10+ return Deno.makeTempDir({ dir: ".", prefix: ".lofi-doctor-test-" });11+}12+13+Deno.test("doctor reports value-free local capability states without booting Astro", async () => {14+ const cwd = await makeTestRoot();15+ try {16+ const project = await createProject({ cwd, name: "starter" });17+ const report = await doctorReport({18+ root: project.destination,19+ environment: {},20+ denoVersion: "2.9.0",21+ });22+ assert(!report.blocked, "valid generated project should not be blocked");23+ assert(report.validation.mode === "local-only", "empty environment should be local-only");24+ assert(25+ report.diagnostics.some((item) => item.name === "Storage" && item.status === "pending"),26+ "doctor must defer browser-only storage facts",27+ );28+ assert(29+ report.diagnostics.some((item) => item.name === "Device URL" && item.status === "pending"),30+ "doctor must report the stable device URL gap",31+ );32+ } finally {33+ await Deno.remove(cwd, { recursive: true });34+ }35+});36+37+Deno.test("doctor blocks partial configuration without exposing values", async () => {38+ const cwd = await makeTestRoot();39+ const secretValue = "must-not-appear-in-doctor-output";40+ try {41+ const project = await createProject({ cwd, name: "starter" });42+ const report = await doctorReport({43+ root: project.destination,44+ environment: {45+ JAZZ_APP_ID: secretValue,46+ JAZZ_ADMIN_SECRET: secretValue,47+ },48+ denoVersion: "2.9.0",49+ });50+ assert(report.blocked, "partial cloud configuration must block boot");51+ assert(report.validation.mode === "invalid", "partial cloud configuration must be invalid");52+ assert(53+ !JSON.stringify(report).includes(secretValue),54+ "doctor report exposed an environment value",55+ );56+ assert(57+ report.diagnostics.some((item) => item.remediation?.includes("edit `.env` once")),58+ "configuration blocker must include a one-edit remediation",59+ );60+ } finally {61+ await Deno.remove(cwd, { recursive: true });62+ }63+});64+65+Deno.test("doctor blocks unsupported Deno and incomplete generated layouts", async () => {66+ const root = await makeTestRoot();67+ try {68+ await Deno.writeTextFile(join(root, "deno.json"), "{}\n");69+ const report = await doctorReport({ root, environment: {}, denoVersion: "2.8.9" });70+ assert(report.blocked, "unsupported runtime and missing files must block boot");71+ assert(72+ report.diagnostics.some((item) => item.name === "Deno" && item.status === "blocker"),73+ "old Deno was not reported",74+ );75+ assert(76+ report.diagnostics.some((item) => item.name === "Project" && item.status === "blocker"),77+ "missing project files were not reported",78+ );79+ } finally {80+ await Deno.remove(root, { recursive: true });81+ }82+});@@ -0,0 +1,168 @@
1+export const clientEnvironmentNames = ["JAZZ_APP_ID", "JAZZ_SERVER_URL"] as const;2+export const serverEnvironmentNames = ["JAZZ_ADMIN_SECRET", "BACKEND_SECRET"] as const;3+export const environmentNames = [...clientEnvironmentNames, ...serverEnvironmentNames] as const;4+5+interface EnvironmentResultBase {6+ presentClientNames: string[];7+ presentServerNames: string[];8+ warnings: string[];9+}10+11+export type EnvironmentValidation =12+ & EnvironmentResultBase13+ & (14+ | {15+ ok: true;16+ mode: "local-only";17+ client: Record<string, never>;18+ }19+ | {20+ ok: true;21+ mode: "cloud-configured";22+ client: { JAZZ_APP_ID: string; JAZZ_SERVER_URL: string };23+ }24+ | {25+ ok: false;26+ mode: "invalid";27+ errors: string[];28+ }29+ );30+31+function isPresent(env: Readonly<Record<string, string>>, name: string): boolean {32+ return (env[name] ?? "").trim().length > 0;33+}34+35+export function validateEnvironment(36+ env: Readonly<Record<string, string>>,37+): EnvironmentValidation {38+ const presentClientNames = clientEnvironmentNames.filter((name) => isPresent(env, name));39+ const presentServerNames = serverEnvironmentNames.filter((name) => isPresent(env, name));40+ const errors: string[] = [];41+ const warnings: string[] = [];42+43+ if (44+ presentClientNames.length > 0 && presentClientNames.length !== clientEnvironmentNames.length45+ ) {46+ const missing = clientEnvironmentNames.filter((name) => !isPresent(env, name));47+ errors.push(48+ `Cloud sync cannot start because configuration is incomplete; set ${49+ missing.join(", ")50+ } or remove the partial JAZZ_APP_ID/JAZZ_SERVER_URL pair to run local-only.`,51+ );52+ }53+54+ if (isPresent(env, "JAZZ_SERVER_URL")) {55+ try {56+ const url = new URL(env.JAZZ_SERVER_URL);57+ if (!new Set(["http:", "https:"]).has(url.protocol)) {58+ errors.push(59+ "Cloud sync cannot start because JAZZ_SERVER_URL must use http or https; correct the URL or remove the cloud configuration to run local-only.",60+ );61+ }62+ } catch {63+ errors.push(64+ "Cloud sync cannot start because JAZZ_SERVER_URL is not a valid URL; correct it or remove the cloud configuration to run local-only.",65+ );66+ }67+ }68+69+ const base: EnvironmentResultBase = {70+ presentClientNames: [...presentClientNames],71+ presentServerNames: [...presentServerNames],72+ warnings,73+ };74+ if (errors.length > 0) return { ...base, ok: false, mode: "invalid", errors };75+ if (presentClientNames.length === 0) {76+ if (presentServerNames.length > 0) {77+ warnings.push(78+ "Server-only credentials are present without cloud client configuration; they remain isolated and unused by the client.",79+ );80+ }81+ return { ...base, ok: true, mode: "local-only", client: {} };82+ }83+ return {84+ ...base,85+ ok: true,86+ mode: "cloud-configured",87+ client: {88+ JAZZ_APP_ID: env.JAZZ_APP_ID.trim(),89+ JAZZ_SERVER_URL: env.JAZZ_SERVER_URL.trim(),90+ },91+ };92+}93+94+export function parseDotenv(source: string): Record<string, string> {95+ const parsed: Record<string, string> = {};96+ for (const originalLine of source.split(/\r?\n/)) {97+ let line = originalLine.trim();98+ if (line.length === 0 || line.startsWith("#")) continue;99+ if (line.startsWith("export ")) line = line.slice("export ".length).trimStart();100+ const separator = line.indexOf("=");101+ if (separator < 1) continue;102+ const name = line.slice(0, separator).trim();103+ if (!environmentNames.includes(name as (typeof environmentNames)[number])) continue;104+ let value = line.slice(separator + 1).trim();105+ if (106+ value.length >= 2 &&107+ ((value.startsWith('"') && value.endsWith('"')) ||108+ (value.startsWith("'") && value.endsWith("'")))109+ ) {110+ value = value.slice(1, -1);111+ }112+ parsed[name] = value;113+ }114+ return parsed;115+}116+117+export async function loadEnvironment(118+ path = ".env",119+ processLookup: (name: string) => string | undefined = (name) => Deno.env.get(name),120+): Promise<Record<string, string>> {121+ let fileEnvironment: Record<string, string> = {};122+ try {123+ fileEnvironment = parseDotenv(await Deno.readTextFile(path));124+ } catch (error) {125+ if (!(error instanceof Deno.errors.NotFound)) throw error;126+ }127+ return Object.fromEntries(128+ environmentNames.map((name) => [name, processLookup(name) ?? fileEnvironment[name] ?? ""]),129+ );130+}131+132+const childEnvironmentAllowlist = [133+ "CI",134+ "DENO_DIR",135+ "HOME",136+ "LANG",137+ "LC_ALL",138+ "NIX_SSL_CERT_FILE",139+ "NO_PROXY",140+ "PATH",141+ "PLAYWRIGHT_BROWSERS_PATH",142+ "SSL_CERT_FILE",143+ "TEMP",144+ "TMP",145+ "TMPDIR",146+] as const;147+148+export function childEnvironment(149+ validation: Exclude<EnvironmentValidation, { ok: false }>,150+ source: Record<string, string> = Deno.env.toObject(),151+): Record<string, string> {152+ const environment: Record<string, string> = {};153+ for (const name of childEnvironmentAllowlist) {154+ const value = source[name];155+ if (value) environment[name] = value;156+ }157+ if (validation.mode === "cloud-configured") {158+ environment.JAZZ_APP_ID = validation.client.JAZZ_APP_ID;159+ environment.JAZZ_SERVER_URL = validation.client.JAZZ_SERVER_URL;160+ } else {161+ environment.JAZZ_APP_ID = "";162+ environment.JAZZ_SERVER_URL = "";163+ environment.VITE_JAZZ_APP_ID = "00000000-0000-0000-0000-00000000f153";164+ }165+ environment.JAZZ_ADMIN_SECRET = "";166+ environment.BACKEND_SECRET = "";167+ return environment;168+}@@ -0,0 +1,41 @@
1+export async function runDeno(2+ args: readonly string[],3+ environment: Readonly<Record<string, string>>,4+ cwd = Deno.cwd(),5+): Promise<number> {6+ const child = new Deno.Command(Deno.execPath(), {7+ args: [...args],8+ cwd,9+ clearEnv: true,10+ env: { ...environment },11+ stdin: "inherit",12+ stdout: "piped",13+ stderr: "piped",14+ }).spawn();15+ const forward = (16+ stream: ReadableStream<Uint8Array>,17+ target: WritableStream<Uint8Array>,18+ ) => stream.pipeTo(target, { preventClose: true });19+ const forwardSignal = (signal: Deno.Signal) => {20+ try {21+ child.kill(signal);22+ } catch (error) {23+ if (!(error instanceof Deno.errors.NotFound)) throw error;24+ }25+ };26+ const onInterrupt = () => forwardSignal("SIGINT");27+ const onTerminate = () => forwardSignal("SIGTERM");28+ Deno.addSignalListener("SIGINT", onInterrupt);29+ Deno.addSignalListener("SIGTERM", onTerminate);30+ try {31+ const [status] = await Promise.all([32+ child.status,33+ forward(child.stdout, Deno.stdout.writable),34+ forward(child.stderr, Deno.stderr.writable),35+ ]);36+ return status.code;37+ } finally {38+ Deno.removeSignalListener("SIGINT", onInterrupt);39+ Deno.removeSignalListener("SIGTERM", onTerminate);40+ }41+}@@ -0,0 +1,140 @@
1+import { extname, join, relative, resolve } from "node:path";2+3+const ignoredDirectories = new Set([4+ ".astro",5+ ".git",6+ ".playwright-cli",7+ ".vite",8+ "coverage",9+ "node_modules",10+ "playwright-report",11+ "test-results",12+]);13+14+export type ProjectCheck = {15+ name: string;16+ status: "ok" | "blocker";17+ detail: string;18+ remediation?: string;19+};20+21+export async function projectChecks(root = Deno.cwd()): Promise<ProjectCheck[]> {22+ const required = [23+ "deno.json",24+ "astro.config.ts",25+ "src/schema.ts",26+ "src/permissions.ts",27+ "src/app.ts",28+ "src/pages/index.astro",29+ "src/islands/ChecklistIsland.tsx",30+ "src/_lofi/boot.ts",31+ "public/manifest.webmanifest",32+ "public/sw.js",33+ ];34+ const missing: string[] = [];35+ for (const path of required) {36+ try {37+ if (!(await Deno.stat(join(root, path))).isFile) missing.push(path);38+ } catch (error) {39+ if (error instanceof Deno.errors.NotFound) missing.push(path);40+ else throw error;41+ }42+ }43+ return missing.length === 044+ ? [{ name: "Project", status: "ok", detail: "generated layout complete" }]45+ : [{46+ name: "Project",47+ status: "blocker",48+ detail: `missing ${missing.join(", ")}`,49+ remediation: "restore the generated files or create a fresh project and move author files",50+ }];51+}52+53+export async function walkFiles(54+ root = Deno.cwd(),55+ options: { includeDist?: boolean; extensions?: ReadonlySet<string> } = {},56+): Promise<string[]> {57+ const absoluteRoot = resolve(root);58+ const files: string[] = [];59+ async function visit(path: string) {60+ const entries = [...Deno.readDirSync(path)].sort((left, right) =>61+ left.name.localeCompare(right.name)62+ );63+ for (const entry of entries) {64+ if (65+ entry.name === ".env" || (entry.name.startsWith(".env.") && entry.name !== ".env.example")66+ ) {67+ continue;68+ }69+ if (entry.isDirectory) {70+ if (ignoredDirectories.has(entry.name) && !(entry.name === "dist" && options.includeDist)) {71+ continue;72+ }73+ await visit(join(path, entry.name));74+ } else if (entry.isFile) {75+ const child = join(path, entry.name);76+ if (!options.extensions || options.extensions.has(extname(child))) {77+ files.push(relative(absoluteRoot, child));78+ }79+ } else {80+ throw new Error(81+ `project contains unsupported symbolic link: ${relative(absoluteRoot, path)}`,82+ );83+ }84+ }85+ }86+ await visit(absoluteRoot);87+ return files.sort();88+}89+90+export async function sourceFingerprint(root = Deno.cwd()): Promise<string> {91+ const files = await walkFiles(root, {92+ extensions: new Set([".astro", ".css", ".json", ".svg", ".ts", ".tsx", ".webmanifest"]),93+ });94+ const encoder = new TextEncoder();95+ const chunks: Uint8Array[] = [];96+ let length = 0;97+ for (const path of files) {98+ const header = encoder.encode(`${path}\0`);99+ const content = await Deno.readFile(join(root, path));100+ chunks.push(header, content);101+ length += header.length + content.length;102+ }103+ const input = new Uint8Array(length);104+ let offset = 0;105+ for (const chunk of chunks) {106+ input.set(chunk, offset);107+ offset += chunk.length;108+ }109+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", input));110+ return Array.from(digest).map((byte) => byte.toString(16).padStart(2, "0")).join("");111+}112+113+function contains(haystack: Uint8Array, needle: Uint8Array): boolean {114+ if (needle.length === 0 || haystack.length < needle.length) return false;115+ outer:116+ for (let offset = 0; offset <= haystack.length - needle.length; offset++) {117+ for (let index = 0; index < needle.length; index++) {118+ if (haystack[offset + index] !== needle[index]) continue outer;119+ }120+ return true;121+ }122+ return false;123+}124+125+export async function scanSecrets(126+ secrets: Readonly<Record<string, string>>,127+ root = Deno.cwd(),128+): Promise<{ scanned: number; leaks: Array<{ name: string; path: string }> }> {129+ const values = Object.entries(secrets).filter(([, value]) => value.trim().length > 0);130+ const files = await walkFiles(root, { includeDist: true });131+ const encoder = new TextEncoder();132+ const leaks: Array<{ name: string; path: string }> = [];133+ for (const path of files) {134+ const content = await Deno.readFile(join(root, path));135+ for (const [name, value] of values) {136+ if (contains(content, encoder.encode(value))) leaks.push({ name, path });137+ }138+ }139+ return { scanned: files.length, leaks };140+}@@ -0,0 +1,55 @@
1+import { join } from "node:path";2+import { scanSecrets, sourceFingerprint } from "./project.ts";3+4+function assert(condition: unknown, message: string): asserts condition {5+ if (!condition) throw new Error(message);6+}7+8+function makeTestRoot(): Promise<string> {9+ return Deno.makeTempDir({ dir: ".", prefix: ".lofi-project-test-" });10+}11+12+Deno.test("source fingerprint is stable across file creation order and changes with content", async () => {13+ const left = await makeTestRoot();14+ const right = await makeTestRoot();15+ try {16+ await Deno.mkdir(join(left, "src"));17+ await Deno.writeTextFile(join(left, "src", "b.ts"), "export const b = 2;\n");18+ await Deno.writeTextFile(join(left, "src", "a.ts"), "export const a = 1;\n");19+ await Deno.mkdir(join(right, "src"));20+ await Deno.writeTextFile(join(right, "src", "a.ts"), "export const a = 1;\n");21+ await Deno.writeTextFile(join(right, "src", "b.ts"), "export const b = 2;\n");22+ assert(23+ await sourceFingerprint(left) === await sourceFingerprint(right),24+ "fingerprint depends on file creation order",25+ );26+ await Deno.writeTextFile(join(right, "src", "b.ts"), "export const b = 3;\n");27+ assert(28+ await sourceFingerprint(left) !== await sourceFingerprint(right),29+ "fingerprint ignored source content",30+ );31+ } finally {32+ await Deno.remove(left, { recursive: true });33+ await Deno.remove(right, { recursive: true });34+ }35+});36+37+Deno.test("secret scan ignores the local env source but catches source and build leaks", async () => {38+ const root = await makeTestRoot();39+ const secret = "generated-command-secret-fixture";40+ try {41+ await Deno.mkdir(join(root, "src"));42+ await Deno.mkdir(join(root, "dist"));43+ await Deno.writeTextFile(join(root, ".env"), `BACKEND_SECRET=${secret}\n`);44+ await Deno.writeTextFile(join(root, "src", "safe.ts"), "export const safe = true;\n");45+ await Deno.writeTextFile(46+ join(root, "dist", "client.js"),47+ `const leaked = ${JSON.stringify(secret)};`,48+ );49+ const result = await scanSecrets({ BACKEND_SECRET: secret }, root);50+ assert(result.leaks.length === 1, `expected one build leak, received ${result.leaks.length}`);51+ assert(result.leaks[0].path === "dist/client.js", "secret scan reported the wrong file");52+ } finally {53+ await Deno.remove(root, { recursive: true });54+ }55+});