clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

feat(tooling): graduate generated dev and doctor

Dami committed a120def
commit
a120def02c295889f0595f0e649bb8433ea8bec7
parent
f561598c4516471b874320bc610408c9e928bc3a
author
Dami <dami.etoile@gmail.com>
22 files changed, +988 -29
ALICENSE21++++
MREADME.md13++-
Mapps/reference/README.md11+-
Mdeno.json5+-
Mdocs/m2-abstraction-leaks.md16+-
Mjsr.json10++-
Apackage/commands/build.ts59+++++++++++
Apackage/commands/check.ts47++++++++
Apackage/commands/dev.ts33++++++
Apackage/commands/doctor.ts7+
Apackage/commands/preview.ts53+++++++++
Apackage/commands/run_tests.ts15+++
Apackage/commands/shared.ts21++++
Mpackage/create_core.ts21+++-
Mpackage/create_core_test.ts63+++++++++++-
Mpackage/testdata/starter.snapshot.json4+-
Apackage/tooling/diagnostics.ts132++++++++++++++++++++++++
Apackage/tooling/diagnostics_test.ts82+++++++++++++++
Apackage/tooling/environment.ts168++++++++++++++++++++++++++++++
Apackage/tooling/process.ts41+++++++
Apackage/tooling/project.ts140+++++++++++++++++++++++++
Apackage/tooling/project_test.ts55++++++++++
A LICENSE +21 -0
M README.md +9 -4
@@ -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
M apps/reference/README.md +6 -5
@@ -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.
M deno.json +3 -2
@@ -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",
M docs/m2-abstraction-leaks.md +8 -8
@@ -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.
M jsr.json +9 -1
A package/commands/build.ts +59 -0
@@ -0,0 +1,59 @@
1+#!/usr/bin/env -S deno run -A2+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+);
A package/commands/check.ts +47 -0
@@ -0,0 +1,47 @@
1+#!/usr/bin/env -S deno run -A2+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)`);
A package/commands/dev.ts +33 -0
A package/commands/doctor.ts +7 -0
A package/commands/preview.ts +53 -0
@@ -0,0 +1,53 @@
1+#!/usr/bin/env -S deno run -A2+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+});
A package/commands/run_tests.ts +15 -0
A package/commands/shared.ts +21 -0
M package/create_core.ts +15 -6
@@ -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);
M package/create_core_test.ts +62 -1
@@ -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 {
M package/testdata/starter.snapshot.json +2 -2
A package/tooling/diagnostics.ts +132 -0
@@ -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+}
A package/tooling/diagnostics_test.ts +82 -0
@@ -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+});
A package/tooling/environment.ts +168 -0
@@ -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+}
A package/tooling/process.ts +41 -0
A package/tooling/project.ts +140 -0
@@ -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+}
A package/tooling/project_test.ts +55 -0
@@ -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+});