feat: graduate M2 public command path
- commit
- ea3f4823befa4b222aae10069b3127c77ae8f8e1
- parent
- b054684f4fd13794a9e0ac2354a4795b4f975739
- parent
- 53f6531c04e356e4ca1849e0625dad3f3e1c539d
- author
- FelineStateMachine <dami.etoile@gmail.com>
@@ -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.@@ -0,0 +1,34 @@
1+import { DurableStorageUnsupportedError } from "./device-capabilities.ts";2+import { assert } from "./test-assert.ts";3+4+const test = (globalThis as unknown as {5+ Deno: { test(name: string, body: () => void | Promise<void>): void };6+}).Deno.test;7+8+test("unsupported durable storage names missing capabilities and the stable-origin action", () => {9+ const error = new DurableStorageUnsupportedError({10+ secureContext: false,11+ opfs: false,12+ sharedWorker: false,13+ webLocks: false,14+ messageChannel: false,15+ durableDriverSupported: false,16+ displayMode: "browser",17+ });18+ assert(error.name === "DurableStorageUnsupportedError", "durability error needs a stable name");19+ for (20+ const capability of [21+ "secureContext",22+ "opfs",23+ "sharedWorker",24+ "webLocks",25+ "messageChannel",26+ ]27+ ) {28+ assert(error.message.includes(capability), `durability error omitted ${capability}`);29+ }30+ assert(31+ error.message.includes("stable HTTPS device URL"),32+ "durability error omitted the stable-origin remediation",33+ );34+});@@ -25,11 +25,13 @@
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 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",34+ "test:golden:create": "deno run -A tools/golden_path_create.ts",3335 "dev": "deno run --allow-env=JAZZ_APP_ID,JAZZ_SERVER_URL,JAZZ_ADMIN_SECRET,BACKEND_SECRET --allow-read=.env,apps/reference/.env --allow-write=apps/reference/.env --allow-run tools/run_reference.ts dev",3436 "build": "deno run --allow-env=JAZZ_APP_ID,JAZZ_SERVER_URL,JAZZ_ADMIN_SECRET,BACKEND_SECRET --allow-read=.env,apps/reference/.env,apps/reference/dist --allow-write=apps/reference/.env,apps/reference/dist --allow-run tools/run_reference.ts build",3537 "schema:validate": "deno run -A npm:jazz-tools@2.0.0-alpha.53 validate --schema-dir apps/reference/src",@@ -309,6 +309,14 @@ Layer 1 does not close #11: checkout-mode evidence is necessary, but the generat
309309 cannot pass until Layer 2 implements the public create command. Remaining abstraction leaks are310310 tracked in `docs/m2-abstraction-leaks.md` rather than hidden behind premature package APIs.311311 312+Layer 2 has two distinct package gates. Before publication, the generated-project journey invokes313+the real local `./create` entrypoint and uses an explicitly test-only file-URL package override so314+all generated commands execute from the exact source under review. The default generated output315+still pins `jsr:@nzip/lofi` to the manifest version. This source gate does not claim that the316+registry command exists: after source acceptance and explicit publication authorization, a317+fresh-directory smoke must invoke `deno run -A jsr:@nzip/lofi/create <name>` before #13 and #11 can318+close.319+312320 Layer 1 keeps M1's `lofi-prototype-<appId>` OPFS namespace so existing device rows remain visible to313321 the reviewed notes-to-tasks lens. Jazz discovers that lens and its snapshots beside `schema.ts` in314322 `apps/reference/src/migrations`. `deno task check:migrations` couples the current schema hash,@@ -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.@@ -0,0 +1,31 @@
1+{2+ "$schema": "https://jsr.io/schema/config-file.v1.json",3+ "name": "@nzip/lofi",4+ "version": "0.1.0",5+ "license": "MIT",6+ "exports": {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"14+ },15+ "publish": {16+ "include": [17+ "LICENSE",18+ "README.md",19+ "apps/reference/astro.config.ts",20+ "apps/reference/public",21+ "apps/reference/src",22+ "apps/reference/tests",23+ "apps/reference/tsconfig.json",24+ "package"25+ ],26+ "exclude": [27+ "package/**/*_test.ts",28+ "package/testdata"29+ ]30+ }31+}@@ -0,0 +1,55 @@
1+2+3+import { extname, join } from "node:path";4+import { LOFI_VERSION } from "../version.ts";5+import { loadEnvironment, serverEnvironmentNames } from "../tooling/environment.ts";6+import { precacheUrls, 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 = precacheUrls(await walkFiles("dist", { includeDist: true }));34+await Deno.writeTextFile("dist/lofi-precache.json", `${JSON.stringify(precache.sort())}\n`);35+36+const loadedEnvironment = await loadEnvironment();37+const serverSecrets = Object.fromEntries(38+ serverEnvironmentNames.map((name) => [name, loadedEnvironment[name] ?? ""]),39+);40+const secretResult = await scanSecrets(serverSecrets);41+if (secretResult.leaks.length > 0) {42+ for (const leak of secretResult.leaks) {43+ console.error(`error: ${leak.name} value found in ${leak.path}`);44+ }45+ console.error(46+ "error: build output may expose a server secret; remove it and rerun `deno task build`",47+ );48+ Deno.exit(1);49+}50+51+const routes = (await walkFiles("dist", { includeDist: true }))52+ .filter((path) => extname(path) === ".html").length;53+console.log(54+ `lofi build: ${join(Deno.cwd(), "dist")} (${routes} routes, ${sourceHash}, secret scan passed)`,55+);@@ -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,36 @@
1+2+3+import { runDenoStatus } 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 status = await runDenoStatus(25+ ["run", "-A", "npm:astro@7.0.9", "dev", ...forwarded],26+ environment,27+);28+if (status.forwardedSignal) {29+ Deno.exit(0);30+}31+if (status.code !== 0) {32+ console.error(33+ "error: development server stopped; application impact: no local URL is available. Run `deno task doctor`, apply the first action, then rerun `deno task dev`.",34+ );35+ Deno.exit(status.code);36+}@@ -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+}@@ -0,0 +1,34 @@
1+2+3+import { createProject } from "./create_core.ts";4+5+function usage(): never {6+ console.error("usage: deno run -A jsr:@nzip/lofi/create <name>");7+ Deno.exit(2);8+}9+10+if (Deno.args.length !== 1 || Deno.args[0].startsWith("-")) usage();11+12+try {13+ const developmentPrefix = Deno.env.get("LOFI_CREATE_DEVELOPMENT") === "1"14+ ? Deno.env.get("LOFI_CREATE_PACKAGE_PREFIX")15+ : undefined;16+ if (17+ developmentPrefix &&18+ (!developmentPrefix.startsWith("file:") || !developmentPrefix.endsWith("/"))19+ ) {20+ throw new Error("internal development package prefix must be a trailing-slash file URL");21+ }22+ const result = await createProject({23+ cwd: Deno.cwd(),24+ name: Deno.args[0],25+ packagePrefix: developmentPrefix,26+ });27+ console.log(`Created ${result.displayPath}`);28+ console.log("");29+ console.log("Next:");30+ for (const command of result.nextCommands) console.log(` ${command}`);31+} catch (error) {32+ console.error(`error: ${error instanceof Error ? error.message : String(error)}`);33+ Deno.exit(1);34+}@@ -0,0 +1,234 @@
1+import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";2+import { STARTER_TEMPLATE } from "./starter_template.ts";3+import { LOFI_PACKAGE_PREFIX } from "./version.ts";4+5+export type CreateProjectOptions = {6+ cwd: string;7+ name: string;8+ packagePrefix?: string;9+};10+11+export type CreateProjectResult = {12+ destination: string;13+ displayPath: string;14+ nextCommands: readonly [string, string];15+};16+17+const generatedGitignore = `.env18+.env.*19+!.env.example20+21+node_modules/22+.astro/23+.vite/24+dist/25+coverage/26+playwright-report/27+test-results/28+.playwright-cli/29+.nzip-*/30+.DS_Store31+`;32+33+const generatedEnvironment =34+ `# Optional public Jazz cloud configuration. Leave both blank for local-only mode.35+JAZZ_APP_ID=36+JAZZ_SERVER_URL=37+38+# Server-only credentials. Never expose these through PUBLIC_ or client code.39+JAZZ_ADMIN_SECRET=40+BACKEND_SECRET=41+`;42+43+function generatedReadme(name: string): string {44+ return `# ${name}45+46+This local-first PWA was created with \`@nzip/lofi\`.47+48+\`\`\`sh49+deno task dev50+\`\`\`51+52+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 boot when54+you want configuration diagnostics without starting the application.55+56+Public tasks: \`dev\`, \`doctor\`, \`check\`, \`test\`, \`build\`, and \`preview\`.57+`;58+}59+60+function generatedDenoConfig(packagePrefix: string): string {61+ const packageCommand = (subpath: string, localPath: string) =>62+ packagePrefix.startsWith("file:")63+ ? `${packagePrefix}${localPath}`64+ : `${packagePrefix}${subpath}`;65+ const config = {66+ imports: {67+ "@astrojs/check": "npm:@astrojs/check@0.9.9",68+ "@astrojs/preact": "npm:@astrojs/preact@6.0.1",69+ "@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"),76+ "astro": "npm:astro@7.0.9",77+ "astro/config": "npm:astro@7.0.9/config",78+ "jazz-tools": "npm:jazz-tools@2.0.0-alpha.53",79+ "jazz-tools/dev/vite": "npm:jazz-tools@2.0.0-alpha.53/dev/vite",80+ "preact": "npm:preact@10.29.7",81+ "preact/hooks": "npm:preact@10.29.7/hooks",82+ "preact/jsx-dev-runtime": "npm:preact@10.29.7/jsx-dev-runtime",83+ "preact/jsx-runtime": "npm:preact@10.29.7/jsx-runtime",84+ "typescript": "npm:typescript@6.0.3",85+ "vite": "npm:vite@8.0.1",86+ },87+ nodeModulesDir: "auto",88+ compilerOptions: {89+ lib: ["deno.ns", "dom", "dom.iterable", "esnext"],90+ jsx: "react-jsx",91+ jsxImportSource: "preact",92+ },93+ tasks: {94+ dev: "deno run -A @nzip/lofi/dev",95+ doctor: "deno run -A @nzip/lofi/doctor",96+ check: "deno run -A @nzip/lofi/check",97+ test: "deno run -A @nzip/lofi/test",98+ build: "deno run -A @nzip/lofi/build",99+ preview: "deno run -A @nzip/lofi/preview",100+ },101+ fmt: {102+ lineWidth: 100,103+ semiColons: true,104+ singleQuote: false,105+ },106+ lint: {107+ rules: { tags: ["recommended"] },108+ },109+ };110+ return `${JSON.stringify(config, null, 2)}\n`;111+}112+113+function validateName(name: string): string[] {114+ if (name.trim() !== name || name.length === 0) {115+ throw new Error("project name must be a non-empty relative path without surrounding spaces");116+ }117+ if (isAbsolute(name)) throw new Error("project name must be a relative path");118+ const segments = name.split(/[\\/]/);119+ if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {120+ throw new Error("project name cannot contain empty, '.' or '..' path segments");121+ }122+ for (const segment of segments) {123+ if (!/^[a-z0-9][a-z0-9._-]*$/i.test(segment)) {124+ throw new Error(125+ `project path segment ${126+ JSON.stringify(segment)127+ } must start with a letter or number and contain only letters, numbers, '.', '_' or '-'`,128+ );129+ }130+ }131+ return segments;132+}133+134+async function directoryIsEmpty(path: string): Promise<boolean> {135+ try {136+ for await (const _entry of Deno.readDir(path)) return false;137+ return true;138+ } catch (error) {139+ if (error instanceof Deno.errors.NotFound) return true;140+ if (error instanceof Deno.errors.NotADirectory) return false;141+ throw error;142+ }143+}144+145+async function assertNoSymlinkPath(cwd: string, segments: readonly string[]): Promise<void> {146+ let path = cwd;147+ for (const segment of segments) {148+ path = join(path, segment);149+ try {150+ const stat = await Deno.lstat(path);151+ if (stat.isSymlink) {152+ throw new Error(153+ `destination path crosses symbolic link ${154+ JSON.stringify(relative(cwd, path))155+ }; no files were changed. Choose a path without symbolic links`,156+ );157+ }158+ } catch (error) {159+ if (error instanceof Deno.errors.NotFound) return;160+ throw error;161+ }162+ }163+}164+165+async function writeTemplate(destination: string): Promise<void> {166+ for (const [relativePath, content] of Object.entries(STARTER_TEMPLATE)) {167+ const path = join(destination, relativePath);168+ await Deno.mkdir(dirname(path), { recursive: true });169+ await Deno.writeTextFile(path, content);170+ }171+}172+173+async function rewritePortableAstroConfig(root: string): Promise<void> {174+ const path = join(root, "astro.config.ts");175+ const source = await Deno.readTextFile(path);176+ const portable = source.replace(177+ 'const workspaceRoot = fileURLToPath(new URL("../../", import.meta.url));',178+ 'const workspaceRoot = fileURLToPath(new URL("./", import.meta.url));',179+ );180+ if (portable === source) throw new Error("starter Astro workspace-root marker was not found");181+ await Deno.writeTextFile(path, portable);182+}183+184+export async function createProject(options: CreateProjectOptions): Promise<CreateProjectResult> {185+ const segments = validateName(options.name);186+ const cwd = resolve(options.cwd);187+ const destination = resolve(cwd, ...segments);188+ const child = relative(cwd, destination);189+ if (child.startsWith(`..${sep}`) || child === "..") {190+ throw new Error("project destination must stay inside the current directory");191+ }192+ await assertNoSymlinkPath(cwd, segments);193+194+ if (!(await directoryIsEmpty(destination))) {195+ throw new Error(196+ `destination ${197+ JSON.stringify(options.name)198+ } already exists and is not empty; no files were changed. Choose a new name, for example: deno run -A jsr:@nzip/lofi/create ${199+ basename(options.name)200+ }-new`,201+ );202+ }203+204+ await Deno.mkdir(dirname(destination), { recursive: true });205+ const staging = await Deno.makeTempDir({206+ dir: dirname(destination),207+ prefix: `.${basename(destination)}-lofi-create-`,208+ });209+ try {210+ await writeTemplate(staging);211+ await rewritePortableAstroConfig(staging);212+ await Deno.writeTextFile(join(staging, ".gitignore"), generatedGitignore);213+ await Deno.writeTextFile(join(staging, ".env.example"), generatedEnvironment);214+ await Deno.writeTextFile(join(staging, "README.md"), generatedReadme(basename(destination)));215+ await Deno.writeTextFile(216+ join(staging, "deno.json"),217+ generatedDenoConfig(options.packagePrefix ?? LOFI_PACKAGE_PREFIX),218+ );219+220+ await Deno.remove(destination).catch((error) => {221+ if (!(error instanceof Deno.errors.NotFound)) throw error;222+ });223+ await Deno.rename(staging, destination);224+ } catch (error) {225+ await Deno.remove(staging, { recursive: true }).catch(() => undefined);226+ throw error;227+ }228+229+ return {230+ destination,231+ displayPath: options.name,232+ nextCommands: [`cd ${options.name}`, "deno task dev"],233+ };234+}@@ -0,0 +1,208 @@
1+import { join } from "node:path";2+import packageManifest from "../jsr.json" with { type: "json" };3+import { createProject } from "./create_core.ts";4+import { LOFI_VERSION } from "./version.ts";5+6+function assert(condition: unknown, message: string): asserts condition {7+ if (!condition) throw new Error(message);8+}9+10+function assertEquals(actual: unknown, expected: unknown): void {11+ const canonical = (value: unknown): unknown => {12+ if (Array.isArray(value)) return value.map(canonical);13+ if (value !== null && typeof value === "object") {14+ return Object.fromEntries(15+ Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(16+ ([key, child]) => [key, canonical(child)],17+ ),18+ );19+ }20+ return value;21+ };22+ const actualJson = JSON.stringify(canonical(actual));23+ const expectedJson = JSON.stringify(canonical(expected));24+ if (actualJson !== expectedJson) {25+ throw new Error(`expected ${expectedJson}, received ${actualJson}`);26+ }27+}28+29+async function assertRejects(30+ action: () => Promise<unknown>,31+ messageIncludes: string,32+): Promise<void> {33+ try {34+ await action();35+ } catch (error) {36+ const message = error instanceof Error ? error.message : String(error);37+ assert(message.includes(messageIncludes), `expected error to include ${messageIncludes}`);38+ return;39+ }40+ throw new Error("expected promise to reject");41+}42+43+async function fileManifest(root: string): Promise<Record<string, string>> {44+ const manifest: Record<string, string> = {};45+ async function visit(path: string, prefix = "") {46+ const entries = [...Deno.readDirSync(path)].sort((left, right) =>47+ left.name.localeCompare(right.name)48+ );49+ for (const entry of entries) {50+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;51+ const child = join(path, entry.name);52+ if (entry.isDirectory) await visit(child, relative);53+ else if (entry.isFile) {54+ const digest = await crypto.subtle.digest("SHA-256", await Deno.readFile(child));55+ manifest[relative] = Array.from(new Uint8Array(digest))56+ .map((byte) => byte.toString(16).padStart(2, "0"))57+ .join("");58+ }59+ }60+ }61+ await visit(root);62+ return manifest;63+}64+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+74+function makeTestRoot(): Promise<string> {75+ return Deno.makeTempDir({ dir: ".", prefix: ".lofi-create-test-" });76+}77+78+Deno.test("createProject materializes the complete starter snapshot", async () => {79+ const cwd = await makeTestRoot();80+ try {81+ const result = await createProject({ cwd, name: "starter" });82+ assertEquals(result.nextCommands, ["cd starter", "deno task dev"]);83+ const actual = await fileManifest(result.destination);84+ const expected = JSON.parse(85+ await Deno.readTextFile(new URL("./testdata/starter.snapshot.json", import.meta.url)),86+ );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+ }101+ const config = JSON.parse(await Deno.readTextFile(join(result.destination, "deno.json")));102+ 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+ );114+ } finally {115+ await Deno.remove(cwd, { recursive: true });116+ }117+});118+119+Deno.test("package manifest and generated version stay coupled", () => {120+ assertEquals(packageManifest.name, "@nzip/lofi");121+ assertEquals(packageManifest.version, LOFI_VERSION);122+});123+124+Deno.test("createProject refuses a non-empty destination without changing it", async () => {125+ const cwd = await makeTestRoot();126+ const destination = join(cwd, "taken");127+ const marker = join(destination, "keep.txt");128+ try {129+ await Deno.mkdir(destination);130+ await Deno.writeTextFile(marker, "untouched");131+ await assertRejects(() => createProject({ cwd, name: "taken" }), "no files were changed");132+ assertEquals(await Deno.readTextFile(marker), "untouched");133+ assertEquals([...Deno.readDirSync(destination)].map((entry) => entry.name), ["keep.txt"]);134+ } finally {135+ await Deno.remove(cwd, { recursive: true });136+ }137+});138+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+160+Deno.test("createProject accepts an existing empty directory and a local package override", async () => {161+ const cwd = await makeTestRoot();162+ try {163+ await Deno.mkdir(join(cwd, "nested", "starter"), { recursive: true });164+ const result = await createProject({165+ cwd,166+ name: "nested/starter",167+ packagePrefix: "file:///clean-room/package/",168+ });169+ const config = JSON.parse(await Deno.readTextFile(join(result.destination, "deno.json")));170+ assertEquals(config.imports["@nzip/lofi/"], "file:///clean-room/package/");171+ assert(172+ !await Array.fromAsync(Deno.readDir(result.destination)).then((entries) =>173+ entries.some((entry) => entry.name === ".env")174+ ),175+ "generated project must not contain .env",176+ );177+ } finally {178+ await Deno.remove(cwd, { recursive: true });179+ }180+});181+182+Deno.test({183+ name: "createProject refuses destinations reached through a symbolic link",184+ ignore: Deno.build.os === "windows",185+ async fn() {186+ const cwd = await makeTestRoot();187+ const outside = await makeTestRoot();188+ try {189+ const link = await new Deno.Command("ln", {190+ args: ["-s", outside, join(cwd, "linked")],191+ stdout: "null",192+ stderr: "piped",193+ }).output();194+ assert(195+ link.success,196+ `failed to create test symlink: ${new TextDecoder().decode(link.stderr)}`,197+ );198+ await assertRejects(199+ () => createProject({ cwd, name: "linked/starter" }),200+ "crosses symbolic link",201+ );202+ assertEquals([...Deno.readDirSync(outside)].length, 0);203+ } finally {204+ await Deno.remove(cwd, { recursive: true });205+ await Deno.remove(outside, { recursive: true });206+ }207+ },208+});@@ -0,0 +1,100 @@
1+import astroConfig from "../apps/reference/astro.config.ts" with { type: "text" };2+import favicon from "../apps/reference/public/favicon.svg" with { type: "text" };3+import manifest from "../apps/reference/public/manifest.webmanifest" with { type: "text" };4+import serviceWorker from "../apps/reference/public/sw.js" with { type: "text" };5+import deviceStatus from "../apps/reference/src/_lofi/DeviceStatus.tsx" with { type: "text" };6+import referenceShell from "../apps/reference/src/_lofi/ReferenceShell.astro" with { type: "text" };7+import boot from "../apps/reference/src/_lofi/boot.ts" with { type: "text" };8+import checklistStore from "../apps/reference/src/_lofi/checklist-store.ts" with { type: "text" };9+import checklistStoreTest from "../apps/reference/src/_lofi/checklist-store_test.ts" with {10+ type: "text",11+};12+import config from "../apps/reference/src/_lofi/config.ts" with { type: "text" };13+import deviceCapabilities from "../apps/reference/src/_lofi/device-capabilities.ts" with {14+ type: "text",15+};16+import deviceCapabilitiesTest from "../apps/reference/src/_lofi/device-capabilities_test.ts" with {17+ type: "text",18+};19+import probe from "../apps/reference/src/_lofi/probe.ts" with { type: "text" };20+import pwa from "../apps/reference/src/_lofi/pwa.ts" with { type: "text" };21+import pwaTest from "../apps/reference/src/_lofi/pwa_test.ts" with { type: "text" };22+import resourceLifecycle from "../apps/reference/src/_lofi/resource-lifecycle.ts" with {23+ type: "text",24+};25+import resourceLifecycleTest from "../apps/reference/src/_lofi/resource-lifecycle_test.ts" with {26+ type: "text",27+};28+import runtime from "../apps/reference/src/_lofi/runtime.ts" with { type: "text" };29+import testAssert from "../apps/reference/src/_lofi/test-assert.ts" with { type: "text" };30+import uiMutation from "../apps/reference/src/_lofi/ui-mutation.ts" with { type: "text" };31+import uiMutationTest from "../apps/reference/src/_lofi/ui-mutation_test.ts" with {32+ type: "text",33+};34+import useChecklist from "../apps/reference/src/_lofi/use-checklist.ts" with { type: "text" };35+import useDeviceCapabilities from "../apps/reference/src/_lofi/use-device-capabilities.ts" with {36+ type: "text",37+};38+import app from "../apps/reference/src/app.ts" with { type: "text" };39+import environmentTypes from "../apps/reference/src/env.d.ts" with { type: "text" };40+import checklistIsland from "../apps/reference/src/islands/ChecklistIsland.tsx" with {41+ type: "text",42+};43+import migration from "../apps/reference/src/migrations/20260715T194947-notes-to-tasks-6c62fec42c35-ff85ac1d97ee.ts" with {44+ type: "text",45+};46+import oldSchemaSnapshot from "../apps/reference/src/migrations/snapshots/20260715T194819-6c62fec42c35.json" with {47+ type: "text",48+};49+import currentSchemaSnapshot from "../apps/reference/src/migrations/snapshots/20260715T194947-ff85ac1d97ee.json" with {50+ type: "text",51+};52+import indexPage from "../apps/reference/src/pages/index.astro" with { type: "text" };53+import permissions from "../apps/reference/src/permissions.ts" with { type: "text" };54+import schema from "../apps/reference/src/schema.ts" with { type: "text" };55+import globalStyles from "../apps/reference/src/styles/global.css" with { type: "text" };56+import uiContract from "../apps/reference/src/ui-contract.ts" with { type: "text" };57+import authorBoundaryTest from "../apps/reference/tests/author-boundary_test.ts" with {58+ type: "text",59+};60+import tsconfig from "../apps/reference/tsconfig.json" with { type: "text" };61+62+/** Every source-controlled file copied from the validated reference app into a new project. */63+export const STARTER_TEMPLATE: Readonly<Record<string, string>> = {64+ "astro.config.ts": astroConfig,65+ "public/favicon.svg": favicon,66+ "public/manifest.webmanifest": manifest,67+ "public/sw.js": serviceWorker,68+ "src/_lofi/DeviceStatus.tsx": deviceStatus,69+ "src/_lofi/ReferenceShell.astro": referenceShell,70+ "src/_lofi/boot.ts": boot,71+ "src/_lofi/checklist-store.ts": checklistStore,72+ "src/_lofi/checklist-store_test.ts": checklistStoreTest,73+ "src/_lofi/config.ts": config,74+ "src/_lofi/device-capabilities.ts": deviceCapabilities,75+ "src/_lofi/device-capabilities_test.ts": deviceCapabilitiesTest,76+ "src/_lofi/probe.ts": probe,77+ "src/_lofi/pwa.ts": pwa,78+ "src/_lofi/pwa_test.ts": pwaTest,79+ "src/_lofi/resource-lifecycle.ts": resourceLifecycle,80+ "src/_lofi/resource-lifecycle_test.ts": resourceLifecycleTest,81+ "src/_lofi/runtime.ts": runtime,82+ "src/_lofi/test-assert.ts": testAssert,83+ "src/_lofi/ui-mutation.ts": uiMutation,84+ "src/_lofi/ui-mutation_test.ts": uiMutationTest,85+ "src/_lofi/use-checklist.ts": useChecklist,86+ "src/_lofi/use-device-capabilities.ts": useDeviceCapabilities,87+ "src/app.ts": app,88+ "src/env.d.ts": environmentTypes,89+ "src/islands/ChecklistIsland.tsx": checklistIsland,90+ "src/migrations/20260715T194947-notes-to-tasks-6c62fec42c35-ff85ac1d97ee.ts": migration,91+ "src/migrations/snapshots/20260715T194819-6c62fec42c35.json": oldSchemaSnapshot,92+ "src/migrations/snapshots/20260715T194947-ff85ac1d97ee.json": currentSchemaSnapshot,93+ "src/pages/index.astro": indexPage,94+ "src/permissions.ts": permissions,95+ "src/schema.ts": schema,96+ "src/styles/global.css": globalStyles,97+ "src/ui-contract.ts": uiContract,98+ "tests/author-boundary_test.ts": authorBoundaryTest,99+ "tsconfig.json": tsconfig,100+};@@ -0,0 +1,42 @@
1+{2+ ".env.example": "c22aed73c7089168ac777fd4f68ad504f9b826ea7185b705514a4ac5505918b1",3+ ".gitignore": "9efb15c6a9bfc20f296ed712e74273b6015a0dd9a40f7e861b8711dcf61825f1",4+ "README.md": "26fb40f86d03a403519bbce0f263b2305971b98c310a19cec37ab0079130fb0c",5+ "astro.config.ts": "8a19cae348af7fcbd9db119d2423dd2e464f0e5ace6e5723d06cd3a1cc9a0d4b",6+ "deno.json": "11183a356cb65dc17394945622fffd16828c5f47fb88388451b61850c620d3f8",7+ "public/favicon.svg": "23efdb888dae688715faafe3ef94f7b5ad1976afcc715c0a742218b54732c6ad",8+ "public/manifest.webmanifest": "d23457fe8df68a16181fde1d4ecca4b69d3b050b5fa3bf8f4051cb05bde1d3d6",9+ "public/sw.js": "d816161fd5a35f2c477ca72eaa5ba58eb8d94795a4399d29a19838e886dafa31",10+ "src/_lofi/DeviceStatus.tsx": "1ba2e2c2bee7ac15bc2830e879904deddf95bba1b55359f0376cf7cb1c9aafeb",11+ "src/_lofi/ReferenceShell.astro": "a2817146d1fe62cfdc399669709996b468823a8828257abf63b445c4c8eac912",12+ "src/_lofi/boot.ts": "dd66bd1e5ddb88599a0cbadb2c2cf932993734843c7e5cf41d28b389560a32c7",13+ "src/_lofi/checklist-store.ts": "8e9b36bc82e431ef63997915407992aa52be2a9606a023546d83b224e28cfd0e",14+ "src/_lofi/checklist-store_test.ts": "00a7bbf786b90e6bd765e3fe401331233a6fad29a9d473dc4b1f0d5146e4dbc2",15+ "src/_lofi/config.ts": "3ae7e31b3915e12eb595eeea158c0dbd026f4f0f8bf7b0f4809117bec9752f1a",16+ "src/_lofi/device-capabilities.ts": "91b03f9bed813d3770061416d40066ed6f21829877293e5395eff8f0f98fc11e",17+ "src/_lofi/device-capabilities_test.ts": "0eece375a0645e915294c7647cba4666e992a8a471cb636cf889bcd323a65ee5",18+ "src/_lofi/probe.ts": "72c8a80561a6b5650d943456e1ce36f08d883fc1008fddee4614a2b75a8f06e9",19+ "src/_lofi/pwa.ts": "185a0668528b49eae2dd0aa905a5a2e491b3787da335d491ffecaf8bfadec430",20+ "src/_lofi/pwa_test.ts": "092d9ff442d82086a03cf45c6ed0256ab0f1ce8db42fe7e636d4b93d6ebae63e",21+ "src/_lofi/resource-lifecycle.ts": "4e017f4391f18854486044709041f163308fa3fc84e08d5ec7f45c39e3ae3cb5",22+ "src/_lofi/resource-lifecycle_test.ts": "9a43053351b8b90f3b67a2953c3ff159e0337ab8009f1d061e9e2bf7382abf43",23+ "src/_lofi/runtime.ts": "7ebbc54c66df6c92f6ecb2e6e24e131c4c1922044a86aa0fd168cdaf88a5eb19",24+ "src/_lofi/test-assert.ts": "38549889964a4e92a0d63ee514c746cae5a43e6160cea4f2075a9e61543d3744",25+ "src/_lofi/ui-mutation.ts": "3273053a796160556045d06d672f4c79d9597e1f822928f1ee71a7d31865dc32",26+ "src/_lofi/ui-mutation_test.ts": "16153da95e080570af254eb0c56433f2db8abacec542984f59f6afd4ebf1c501",27+ "src/_lofi/use-checklist.ts": "a6d2818d390d228ad2a3db554b7cb043b60f7e9a2e48636ba19247c666343eaf",28+ "src/_lofi/use-device-capabilities.ts": "f7c7b2321b400d8076a5347e0f1ff2924623535979d48a2d94a69f825b93841e",29+ "src/app.ts": "3fd3646054f11ef7a1c080445d089eba45d58df7934aeaa58edab7c965c65523",30+ "src/env.d.ts": "b44daed05ec5cdfacfd8d8acf7866974b5f6b8db923ab53bd244419093c719da",31+ "src/islands/ChecklistIsland.tsx": "77c8c085327b7832d0d9daa0f39a61d3ecf1dc6f7e50c8146499dd6d81aecbe3",32+ "src/migrations/20260715T194947-notes-to-tasks-6c62fec42c35-ff85ac1d97ee.ts": "387201d32018cf9bbc0da7041110638baf27bb9eb55a5e03f59b124bcfc336d5",33+ "src/migrations/snapshots/20260715T194819-6c62fec42c35.json": "799e32dee2ca64b7ea69ae48869f5a6dc29adfdb97dd79767a4c8697495aa582",34+ "src/migrations/snapshots/20260715T194947-ff85ac1d97ee.json": "39bb03925d68138aa5effc6fc9799de90ad4f3b6b65df84b638afc7b2d67c16b",35+ "src/pages/index.astro": "1930f00137df528f15836eb68230b06b248f8abec53f1a051a904557a1287346",36+ "src/permissions.ts": "b4580cc5f32935ac568a9aff6109c4aa7e4b3ee8cea8b28c3f306db68a55dcc2",37+ "src/schema.ts": "f2c555b088ded8eea2653f5d955fc9aae69dfd588e891adf509e99052f1e9949",38+ "src/styles/global.css": "7cbe243842f3bca32038d99a4872afe30c346cf7137387ce41861c9333a1f5c0",39+ "src/ui-contract.ts": "fc40d20dff6ba870c0de038839e66cb61d4349031e4a7748d963ca182e8bbec0",40+ "tests/author-boundary_test.ts": "7ea79031ed4ae7d7966bef2e456e02efe7d93d82c8f0bee219d885d300260ed2",41+ "tsconfig.json": "fe2e5acc66101ed42d893d7e7063d07d298927621b351adcfb1ebde6c3a0a2ac"42+}@@ -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,167 @@
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+ }164+ environment.JAZZ_ADMIN_SECRET = "";165+ environment.BACKEND_SECRET = "";166+ return environment;167+}@@ -0,0 +1,22 @@
1+import { childEnvironment, validateEnvironment } from "./environment.ts";2+3+function assert(condition: unknown, message: string): asserts condition {4+ if (!condition) throw new Error(message);5+}6+7+Deno.test("local child environment clears Jazz configuration without inventing Vite inputs", () => {8+ const validation = validateEnvironment({});9+ assert(validation.ok, "empty configuration should select local-only mode");10+ const environment = childEnvironment(validation, {11+ PATH: "/test/bin",12+ VITE_JAZZ_APP_ID: "ambient-value",13+ UNRELATED: "ambient-value",14+ });15+ assert(environment.PATH === "/test/bin", "allowlisted process input was dropped");16+ assert(environment.JAZZ_APP_ID === "", "local mode retained JAZZ_APP_ID");17+ assert(environment.JAZZ_SERVER_URL === "", "local mode retained JAZZ_SERVER_URL");18+ assert(environment.JAZZ_ADMIN_SECRET === "", "local mode retained JAZZ_ADMIN_SECRET");19+ assert(environment.BACKEND_SECRET === "", "local mode retained BACKEND_SECRET");20+ assert(!("VITE_JAZZ_APP_ID" in environment), "local mode invented a Vite-only Jazz input");21+ assert(!("UNRELATED" in environment), "child environment retained unrelated ambient input");22+});@@ -0,0 +1,55 @@
1+export type DenoRunStatus = Deno.CommandStatus & {2+ forwardedSignal: Deno.Signal | null;3+};4+5+export async function runDenoStatus(6+ args: readonly string[],7+ environment: Readonly<Record<string, string>>,8+ cwd = Deno.cwd(),9+): Promise<DenoRunStatus> {10+ const child = new Deno.Command(Deno.execPath(), {11+ args: [...args],12+ cwd,13+ clearEnv: true,14+ env: { ...environment },15+ stdin: "inherit",16+ stdout: "piped",17+ stderr: "piped",18+ }).spawn();19+ let forwardedSignal: Deno.Signal | null = null;20+ const forward = (21+ stream: ReadableStream<Uint8Array>,22+ target: WritableStream<Uint8Array>,23+ ) => stream.pipeTo(target, { preventClose: true });24+ const forwardSignal = (signal: Deno.Signal) => {25+ try {26+ child.kill(signal);27+ forwardedSignal ??= signal;28+ } catch (error) {29+ if (!(error instanceof Deno.errors.NotFound)) throw error;30+ }31+ };32+ const onInterrupt = () => forwardSignal("SIGINT");33+ const onTerminate = () => forwardSignal("SIGTERM");34+ Deno.addSignalListener("SIGINT", onInterrupt);35+ Deno.addSignalListener("SIGTERM", onTerminate);36+ try {37+ const [status] = await Promise.all([38+ child.status,39+ forward(child.stdout, Deno.stdout.writable),40+ forward(child.stderr, Deno.stderr.writable),41+ ]);42+ return { ...status, forwardedSignal };43+ } finally {44+ Deno.removeSignalListener("SIGINT", onInterrupt);45+ Deno.removeSignalListener("SIGTERM", onTerminate);46+ }47+}48+49+export async function runDeno(50+ args: readonly string[],51+ environment: Readonly<Record<string, string>>,52+ cwd = Deno.cwd(),53+): Promise<number> {54+ return (await runDenoStatus(args, environment, cwd)).code;55+}@@ -0,0 +1,68 @@
1+import { runDenoStatus } from "./process.ts";2+3+function assert(condition: unknown, message: string): asserts condition {4+ if (!condition) throw new Error(message);5+}6+7+Deno.test({8+ name: "runDenoStatus distinguishes a forwarded shutdown signal from a child failure",9+ ignore: Deno.build.os === "windows",10+ async fn() {11+ const moduleUrl = new URL("./process.ts", import.meta.url).href;12+ const wrapperSource = `13+ import { runDenoStatus } from ${JSON.stringify(moduleUrl)};14+ const status = await runDenoStatus(15+ ["eval", "console.log('READY'); await new Promise(() => {})"],16+ {},17+ );18+ console.log("STATUS " + JSON.stringify(status));19+ if (status.forwardedSignal !== "SIGTERM") Deno.exit(1);20+ `;21+ const wrapper = new Deno.Command(Deno.execPath(), {22+ args: ["eval", wrapperSource],23+ clearEnv: true,24+ stdout: "piped",25+ stderr: "piped",26+ }).spawn();27+ const reader = wrapper.stdout.pipeThrough(new TextDecoderStream()).getReader();28+ let stdout = "";29+ try {30+ while (!stdout.includes("READY")) {31+ const next = await Promise.race([32+ reader.read(),33+ new Promise<never>((_, reject) =>34+ setTimeout(() => reject(new Error("signal test child did not become ready")), 5_000)35+ ),36+ ]);37+ if (next.done) throw new Error(`signal test child exited before readiness: ${stdout}`);38+ stdout += next.value;39+ }40+ wrapper.kill("SIGTERM");41+ while (true) {42+ const next = await reader.read();43+ if (next.done) break;44+ stdout += next.value;45+ }46+ const [status, stderr] = await Promise.all([47+ wrapper.status,48+ new Response(wrapper.stderr).text(),49+ ]);50+ assert(status.success, `signal test wrapper failed (${status.code}): ${stderr}`);51+ assert(52+ stdout.includes('"forwardedSignal":"SIGTERM"'),53+ `forwarded signal was not retained: ${stdout}`,54+ );55+ } finally {56+ reader.releaseLock();57+ try {58+ wrapper.kill("SIGKILL");59+ } catch {60+ // The expected path already exited cleanly.61+ }62+ }63+64+ const unexpected = await runDenoStatus(["eval", "Deno.exit(17)"], {});65+ assert(unexpected.code === 17, `unexpected child code changed: ${unexpected.code}`);66+ assert(unexpected.forwardedSignal === null, "ordinary child failure was marked as a shutdown");67+ },68+});@@ -0,0 +1,164 @@
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+ const child = join(path, entry.name);65+ if (66+ entry.name === ".env" || (entry.name.startsWith(".env.") && entry.name !== ".env.example")67+ ) {68+ continue;69+ }70+ if (entry.isDirectory) {71+ if (ignoredDirectories.has(entry.name) && !(entry.name === "dist" && options.includeDist)) {72+ continue;73+ }74+ await visit(join(path, entry.name));75+ } else if (entry.isFile) {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, child)}; ` +82+ "replace it with a regular file or directory, then rerun the command",83+ );84+ }85+ }86+ }87+ await visit(absoluteRoot);88+ return files.sort();89+}90+91+export async function sourceFingerprint(root = Deno.cwd()): Promise<string> {92+ const files = await walkFiles(root, {93+ extensions: new Set([94+ ".astro",95+ ".css",96+ ".js",97+ ".json",98+ ".svg",99+ ".ts",100+ ".tsx",101+ ".webmanifest",102+ ]),103+ });104+ const encoder = new TextEncoder();105+ const chunks: Uint8Array[] = [];106+ let length = 0;107+ for (const path of files) {108+ const header = encoder.encode(`${path}\0`);109+ const content = await Deno.readFile(join(root, path));110+ chunks.push(header, content);111+ length += header.length + content.length;112+ }113+ const input = new Uint8Array(length);114+ let offset = 0;115+ for (const chunk of chunks) {116+ input.set(chunk, offset);117+ offset += chunk.length;118+ }119+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", input));120+ return Array.from(digest).map((byte) => byte.toString(16).padStart(2, "0")).join("");121+}122+123+const precacheExcludedPaths = new Set([124+ "lofi-build.json",125+ "lofi-precache.json",126+ "sw.js",127+]);128+129+export function precacheUrls(paths: readonly string[]): string[] {130+ return paths131+ .map((path) => path.replaceAll("\\", "/"))132+ .filter((path) => !precacheExcludedPaths.has(path))133+ .map((path) => path === "index.html" ? "./" : `./${path}`)134+ .sort();135+}136+137+function contains(haystack: Uint8Array, needle: Uint8Array): boolean {138+ if (needle.length === 0 || haystack.length < needle.length) return false;139+ outer:140+ for (let offset = 0; offset <= haystack.length - needle.length; offset++) {141+ for (let index = 0; index < needle.length; index++) {142+ if (haystack[offset + index] !== needle[index]) continue outer;143+ }144+ return true;145+ }146+ return false;147+}148+149+export async function scanSecrets(150+ secrets: Readonly<Record<string, string>>,151+ root = Deno.cwd(),152+): Promise<{ scanned: number; leaks: Array<{ name: string; path: string }> }> {153+ const values = Object.entries(secrets).filter(([, value]) => value.trim().length > 0);154+ const files = await walkFiles(root, { includeDist: true });155+ const encoder = new TextEncoder();156+ const leaks: Array<{ name: string; path: string }> = [];157+ for (const path of files) {158+ const content = await Deno.readFile(join(root, path));159+ for (const [name, value] of values) {160+ if (contains(content, encoder.encode(value))) leaks.push({ name, path });161+ }162+ }163+ return { scanned: files.length, leaks };164+}@@ -0,0 +1,120 @@
1+import { join } from "node:path";2+import { precacheUrls, scanSecrets, sourceFingerprint, walkFiles } 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("source fingerprint includes authored service worker JavaScript", async () => {38+ const root = await makeTestRoot();39+ try {40+ await Deno.mkdir(join(root, "public"));41+ await Deno.writeTextFile(join(root, "public", "sw.js"), "const revision = 'one';\n");42+ const first = await sourceFingerprint(root);43+ await Deno.writeTextFile(join(root, "public", "sw.js"), "const revision = 'two';\n");44+ assert(first !== await sourceFingerprint(root), "fingerprint ignored service worker source");45+ } finally {46+ await Deno.remove(root, { recursive: true });47+ }48+});49+50+Deno.test("precache URLs are portable app-shell URLs", () => {51+ const urls = precacheUrls([52+ "sw.js",53+ "index.html",54+ "lofi-build.json",55+ "lofi-precache.json",56+ "assets\\client.js",57+ "manifest.webmanifest",58+ ]);59+ assert(60+ JSON.stringify(urls) === JSON.stringify([61+ "./",62+ "./assets/client.js",63+ "./manifest.webmanifest",64+ ]),65+ `unexpected precache URLs: ${JSON.stringify(urls)}`,66+ );67+});68+69+Deno.test({70+ name: "project walking names unsupported symbolic links and the remediation",71+ ignore: Deno.build.os === "windows",72+ async fn() {73+ const root = await makeTestRoot();74+ try {75+ await Deno.writeTextFile(join(root, "target.ts"), "export const target = true;\n");76+ const link = await new Deno.Command("ln", {77+ args: ["-s", "target.ts", join(root, "linked.ts")],78+ stdout: "null",79+ stderr: "piped",80+ }).output();81+ assert(82+ link.success,83+ `failed to create test symlink: ${new TextDecoder().decode(link.stderr)}`,84+ );85+ let message = "";86+ try {87+ await walkFiles(root);88+ } catch (error) {89+ message = error instanceof Error ? error.message : String(error);90+ }91+ assert(message.includes("linked.ts"), `error did not name the symbolic link: ${message}`);92+ assert(93+ message.includes("replace it with a regular file or directory"),94+ `error did not include remediation: ${message}`,95+ );96+ } finally {97+ await Deno.remove(root, { recursive: true });98+ }99+ },100+});101+102+Deno.test("secret scan ignores the local env source but catches source and build leaks", async () => {103+ const root = await makeTestRoot();104+ const secret = "generated-command-secret-fixture";105+ try {106+ await Deno.mkdir(join(root, "src"));107+ await Deno.mkdir(join(root, "dist"));108+ await Deno.writeTextFile(join(root, ".env"), `BACKEND_SECRET=${secret}\n`);109+ await Deno.writeTextFile(join(root, "src", "safe.ts"), "export const safe = true;\n");110+ await Deno.writeTextFile(111+ join(root, "dist", "client.js"),112+ `const leaked = ${JSON.stringify(secret)};`,113+ );114+ const result = await scanSecrets({ BACKEND_SECRET: secret }, root);115+ assert(result.leaks.length === 1, `expected one build leak, received ${result.leaks.length}`);116+ assert(result.leaks[0].path === "dist/client.js", "secret scan reported the wrong file");117+ } finally {118+ await Deno.remove(root, { recursive: true });119+ }120+});@@ -24,6 +24,7 @@ import { checklistUi } from "../apps/reference/src/ui-contract.ts";
2424 import { assert } from "./assert.ts";2525 2626 export type GoldenPathCommands = {27+ doctor?: string;2728 dev: string;2829 check: string;2930 test: string;@@ -42,6 +43,14 @@ export type GoldenPathOptions = {
4243 environmentMode?: "isolated-local" | "cloud-from-root";4344 timeoutMs?: number;4445 islandLabels?: [string, string];46+ hmrPath?: string;47+ initialCommands?: CommandRecord[];48+ sourceRevisionRoot?: string;49+ resetArtifactRoot?: boolean;50+ journeyStartedAt?: Date;51+ journeyStartedPerformanceMs?: number;52+ createDurationMs?: number;53+ developerCommandCount?: number;4554 };4655 4756 const defaultCommands: GoldenPathCommands = {@@ -167,6 +176,40 @@ async function assertRuntimeCardinality(
167176 });168177 }169178 179+async function exerciseHmr(180+ page: Page,181+ projectRoot: string,182+ hmrPath: string,183+ islandLabels: readonly string[],184+ retainedItem: string,185+ timeoutMs: number,186+): Promise<{ samples: number[]; restore: () => Promise<void> }> {187+ const path = resolve(projectRoot, hmrPath);188+ const original = await Deno.readTextFile(path);189+ const samples: number[] = [];190+ for (let cycle = 1; cycle <= 5; cycle++) {191+ const started = performance.now();192+ await Deno.writeTextFile(193+ path,194+ `${original}\n:root {\n --lofi-golden-hmr-cycle: ${cycle};\n}\n`,195+ );196+ await page.waitForFunction(197+ (expected) =>198+ getComputedStyle(document.documentElement).getPropertyValue("--lofi-golden-hmr-cycle")199+ .trim() === expected,200+ String(cycle),201+ { timeout: timeoutMs },202+ );203+ samples.push(Math.round(performance.now() - started));204+ await waitForItem(page, islandLabels, retainedItem);205+ await assertRuntimeCardinality(page, islandLabels);206+ }207+ return {208+ samples,209+ restore: async () => await Deno.writeTextFile(path, original),210+ };211+}212+170213 async function setReferenceConnection(page: Page, connected: boolean): Promise<void> {171214 await page.evaluate(async (shouldConnect) => {172215 const probe = (globalThis as typeof globalThis & {@@ -357,6 +400,7 @@ async function sourceRevision(
357400 358401 export async function runJourney(options: GoldenPathOptions): Promise<JourneyReport> {359402 const sourceRoot = resolve(options.projectRoot ?? Deno.cwd());403+ const revisionRoot = resolve(options.sourceRevisionRoot ?? sourceRoot);360404 const environmentMode = options.environmentMode ?? "isolated-local";361405 const artifacts = artifactPaths(362406 resolve(@@ -369,19 +413,21 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
369413 ),370414 ),371415 );372- await Deno.remove(artifacts.root, { recursive: true }).catch((error) => {373- if (!(error instanceof Deno.errors.NotFound)) throw error;374- });416+ if (options.resetArtifactRoot !== false) {417+ await Deno.remove(artifacts.root, { recursive: true }).catch((error) => {418+ if (!(error instanceof Deno.errors.NotFound)) throw error;419+ });420+ }375421 await Deno.mkdir(artifacts.root, { recursive: true });376422 377- const startedAt = new Date();423+ const startedAt = options.journeyStartedAt ?? new Date();378424 const timeoutMs = options.timeoutMs ?? 60_000;379425 const commands = { ...defaultCommands, ...options.commands };380426 const islandLabels = options.islandLabels ?? ["North island", "South island"];381427 const environment = await journeyEnvironment(sourceRoot, environmentMode, artifacts.root);382428 const redactValues = environmentNames.map((name) => environment[name]).filter(Boolean);383- const commit = await sourceRevision(sourceRoot, environment);384- const commandRecords: CommandRecord[] = [];429+ const commit = await sourceRevision(revisionRoot, environment);430+ const commandRecords: CommandRecord[] = [...options.initialCommands ?? []];385431 const assertions: JourneyAssertion[] = [];386432 let projectRoot = sourceRoot;387433 let browser: Browser | null = null;@@ -392,6 +438,9 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
392438 let browserVersion = "not launched";393439 let devReadyMs: number | undefined;394440 let firstRetainedWriteMs: number | undefined;441+ let createToFirstRetainedWriteMs: number | undefined;442+ let hmrMs: number[] | undefined;443+ let restoreHmr: (() => Promise<void>) | null = null;395444 let failure: unknown;396445 397446 const report: JourneyReport = {@@ -412,7 +461,8 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
412461 },413462 cacheMode: options.cacheMode ?? "existing",414463 measurements: {415- developerCommandCount: 3,464+ developerCommandCount: options.developerCommandCount ?? (options.source === "create" ? 3 : 1),465+ createMs: options.createDurationMs,416466 },417467 commands: commandRecords,418468 assertions,@@ -447,6 +497,24 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
447497 detail: "No forbidden runtime, worker, transport, Workbox, or capability plumbing found.",448498 });449499 500+ if (commands.doctor) {501+ const doctor = await runCapturedCommand({502+ cwd: projectRoot,503+ args: taskArgs(commands.doctor),504+ environment,505+ artifactRoot: artifacts.root,506+ name: "doctor",507+ redactValues,508+ });509+ commandRecords.push(doctor);510+ if (doctor.exitCode !== 0) throw commandFailure(doctor);511+ assertions.push({512+ name: "doctor preflight",513+ status: "passed",514+ detail: "Generated diagnostics completed without starting the application.",515+ });516+ }517+450518 try {451519 browser = await chromium.launch({ headless: true });452520 } catch (error) {@@ -495,6 +563,11 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
495563 await waitForItem(page, islandLabels, retainedBody);496564 await waitForLocalDurability(page);497565 firstRetainedWriteMs = Math.round(performance.now() - devStarted);566+ if (options.journeyStartedPerformanceMs !== undefined) {567+ createToFirstRetainedWriteMs = Math.round(568+ performance.now() - options.journeyStartedPerformanceMs,569+ );570+ }498571 await page.reload({ waitUntil: "domcontentloaded", timeout: timeoutMs });499572 await waitForItem(page, islandLabels, retainedBody);500573 await assertRuntimeCardinality(page, islandLabels);@@ -505,6 +578,28 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
505578 "The local write survived reload and both islands kept one shared runtime subscription.",506579 });507580 581+ const hmr = await exerciseHmr(582+ page,583+ projectRoot,584+ options.hmrPath ??585+ (options.source === "create"586+ ? "src/styles/global.css"587+ : "apps/reference/src/styles/global.css"),588+ islandLabels,589+ retainedBody,590+ timeoutMs,591+ );592+ hmrMs = hmr.samples;593+ restoreHmr = hmr.restore;594+ assertions.push({595+ name: "HMR state and cardinality",596+ status: "passed",597+ detail:598+ `Five observable style edits retained data with one client, two consumers, and one vendor subscription (${599+ hmrMs.join(", ")600+ }ms).`,601+ });602+508603 await updateItem(page, retainedBody, updatedBody);509604 await waitForItem(page, islandLabels, updatedBody);510605 await completeItem(page, updatedBody);@@ -540,8 +635,22 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
540635 detail: "A loaded page accepted a local write offline and retained it after network return.",541636 });542637 638+ const devStderrPath = dev.stderrPath;543639 await dev.stop();544640 dev = null;641+ const devStderr = await Deno.readTextFile(devStderrPath);642+ assert(643+ !devStderr.includes("error: development server stopped"),644+ `normal development shutdown printed a fatal diagnostic; inspect ${devStderrPath}`,645+ );646+ assertions.push({647+ name: "development shutdown",648+ status: "passed",649+ detail:650+ "The generated development command stopped on the harness signal without a false failure.",651+ });652+ if (restoreHmr) await restoreHmr();653+ restoreHmr = null;545654 for (546655 const [name, task] of [547656 ["check", commands.check],@@ -641,6 +750,7 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
641750 }642751 await preview?.stop().catch(() => undefined);643752 await dev?.stop().catch(() => undefined);753+ await restoreHmr?.().catch(() => undefined);644754 await context?.close().catch(() => undefined);645755 await browser?.close().catch(() => undefined);646756 if (options.source === "checkout" && projectRoot !== sourceRoot) {@@ -655,6 +765,13 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
655765 report.completedAt = new Date().toISOString();656766 report.measurements.devReadyMs = devReadyMs;657767 report.measurements.firstRetainedWriteMs = firstRetainedWriteMs;768+ report.measurements.createToFirstRetainedWriteMs = createToFirstRetainedWriteMs;769+ report.measurements.hmrMs = hmrMs;770+ if (hmrMs?.length) {771+ const sorted = [...hmrMs].sort((left, right) => left - right);772+ report.measurements.hmrMedianMs = sorted[Math.floor(sorted.length / 2)];773+ report.measurements.hmrSlowestMs = sorted.at(-1);774+ }658775 report.runtime.browser = browserVersion;659776 await writeJourneyReport(report);660777 }@@ -44,8 +44,13 @@ export type JourneyReport = {
4444 cacheMode: "existing" | "cold";4545 measurements: {4646 developerCommandCount: number;47+ createMs?: number;48+ createToFirstRetainedWriteMs?: number;4749 devReadyMs?: number;4850 firstRetainedWriteMs?: number;51+ hmrMs?: number[];52+ hmrMedianMs?: number;53+ hmrSlowestMs?: number;4954 };5055 commands: CommandRecord[];5156 assertions: JourneyAssertion[];@@ -0,0 +1,79 @@
1+import { join, resolve } from "node:path";2+import { fileURLToPath } from "node:url";3+import { runJourney } from "./golden_path.ts";4+import {5+ installNodeSentinel,6+ runCapturedCommand,7+ safeChildEnvironment,8+} from "./golden_path_core.ts";9+import { assert } from "./assert.ts";10+11+const sourceRoot = resolve(Deno.cwd());12+const artifactRoot = join(sourceRoot, "test-results/golden-path-create");13+const temporaryRoot = await Deno.makeTempDir({ prefix: "lofi-create-golden-" });14+const projectRoot = join(temporaryRoot, "lofi-app");15+const journeyStartedAt = new Date();16+const journeyStartedPerformanceMs = performance.now();17+18+try {19+ await Deno.remove(artifactRoot, { recursive: true }).catch((error) => {20+ if (!(error instanceof Deno.errors.NotFound)) throw error;21+ });22+ await Deno.mkdir(artifactRoot, { recursive: true });23+ const environment = await installNodeSentinel(safeChildEnvironment(), artifactRoot);24+ environment.LOFI_CREATE_DEVELOPMENT = "1";25+ environment.LOFI_CREATE_PACKAGE_PREFIX = new URL("../package/", import.meta.url).href;26+ const create = await runCapturedCommand({27+ cwd: temporaryRoot,28+ args: [29+ "run",30+ "-A",31+ fileURLToPath(new URL("../package/create.ts", import.meta.url)),32+ "lofi-app",33+ ],34+ environment,35+ artifactRoot,36+ name: "create",37+ });38+ if (create.exitCode !== 0) {39+ throw new Error(`create failed with exit ${create.exitCode}; inspect ${create.stderrPath}`);40+ }41+ const createOutput = await Deno.readTextFile(create.stdoutPath);42+ assert(createOutput.includes("Created lofi-app"), "create output did not name the created path");43+ assert(createOutput.includes(" cd lofi-app"), "create output did not print the exact cd step");44+ assert(45+ createOutput.includes(" deno task dev"),46+ "create output did not print the exact dev step",47+ );48+49+ const report = await runJourney({50+ source: "create",51+ projectRoot,52+ artifactRoot,53+ sourceRevisionRoot: sourceRoot,54+ resetArtifactRoot: false,55+ initialCommands: [create],56+ commands: { doctor: "doctor" },57+ authorPaths: [58+ "src/app.ts",59+ "src/islands",60+ "src/pages",61+ "src/permissions.ts",62+ "src/schema.ts",63+ "src/styles",64+ ],65+ journeyStartedAt,66+ journeyStartedPerformanceMs,67+ createDurationMs: create.durationMs,68+ developerCommandCount: 3,69+ });70+ console.log(`lofi generated golden path: passed (${report.artifacts.report})`);71+} catch (error) {72+ console.error(73+ `lofi generated golden path: failed: ${error instanceof Error ? error.message : error}`,74+ );75+ console.error("rerun: deno task test:golden:create");76+ Deno.exitCode = 1;77+} finally {78+ await Deno.remove(temporaryRoot, { recursive: true }).catch(() => undefined);79+}