1import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";2import { readStarterFile, STARTER_FILES } from "./starter_template.ts";3import { LOFI_PACKAGE_PREFIX } from "./version.ts";45export type CreateProjectOptions = {6 cwd: string;7 name: string;8 packagePrefix?: string;9};1011export type CreateProjectResult = {12 destination: string;13 displayPath: string;14 nextCommands: readonly [string, string];15};1617const generatedGitignore = `.env18.env.*19!.env.example2021node_modules/22.astro/23.vite/24.lofi/25dist/26coverage/27playwright-report/28test-results/29.playwright-cli/30.nzip-*/31.DS_Store32`;3334const generatedEnvironment =35 `# Public deployment base. Keep / for an origin-root deployment, or use /my-app/ when36# the static host mounts this build below the origin root.37LOFI_BASE_PATH=/3839# Optional public Jazz cloud configuration. Leave both blank for local-only mode.40# Run \`deno task jazz:provision\` to generate a managed Jazz app and fill these in.41JAZZ_APP_ID=42JAZZ_SERVER_URL=4344# Server-only credentials. Never expose these through PUBLIC_ or client code.45JAZZ_ADMIN_SECRET=46BACKEND_SECRET=47`;4849function generatedReadme(name: string): string {50 return `# ${name}5152This local-first PWA was created with \`@nzip/lofi\`.5354[Framework documentation](https://github.com/FelineStateMachine/lofi/tree/main/docs)5556\`\`\`sh57deno task dev58\`\`\`5960The default project runs local-only with durable browser storage — it opens instantly on a private,61on-device account with no sign-in. To enable managed sync and account backup, run62\`deno task jazz:provision\` (it generates a managed Jazz app and writes \`.env\`), then rebuild. Run63\`deno task doctor\` before boot when you want configuration diagnostics without starting the app.6465## Accounts: back up & recover6667With a Jazz app configured, the \`AccountGate\` island lets a user back up and sync their local account,68restore it with a recoverable passkey, or use the portable 24-word recovery phrase fallback. The69account they already have carries over — electing to sync never changes its identity. Passkey restore70is scoped to the app's stable relying-party ID; provider syncing is not universally portable. The71framework implementation is provided by \`@nzip/lofi\`.7273## Sharing and groups7475The optional \`@nzip/lofi/access\` entrypoint provides private, direct-share, and fixed-role group76templates over ordinary Jazz schemas. Collaboration requires configured sync and fails explicitly in77local-only mode. Start with the78[permissions guide](https://github.com/FelineStateMachine/lofi/blob/main/docs/permissions.md) and the79[access API reference](https://github.com/FelineStateMachine/lofi/blob/main/docs/reference/access.md).8081## Optional installed-app recipes8283OS-facing capabilities remain absent until the product opts in. Start with the84[installed-app recipe catalog](https://github.com/FelineStateMachine/lofi/blob/main/docs/recipes/README.md)85for tested manifest patches, feature detection, input validation, offline behavior, and normal-web86fallbacks. Isolated entrypoints cover Web Share, installed-window launch handling, and validated87file-import previews, custom protocol links, and presentation-only companion discovery without adding88any to the starter. Cross-origin app-window scope and desktop titlebar geometry remain explicit89experimental opt-ins.9091Public tasks: \`dev\`, \`doctor\`, \`test\`, \`build\`, and \`preview\`. Sync/backup and schema tasks:92\`jazz:provision\`, \`schema:validate\`, \`schema:deploy\`, \`migrations:create\`, and \`migrations:push\`.9394Start with the framework's95[generated-app guide](https://github.com/FelineStateMachine/lofi/blob/main/docs/getting-started.md)96when replacing the task example with your own schema, permissions, hook, and UI.97The [exact generated-project map](https://github.com/FelineStateMachine/lofi/blob/main/docs/reference/project-layout.md)98lists every source-controlled path and its ownership.99100Runtime, PWA, identity, sync, diagnostics, and Astro/Vite integration come from the one pinned101\`@nzip/lofi\` package version. They are not copied into this project's source.102103## Hosting104105\`deno task build\` emits a static PWA in \`dist/\`. The deploy tasks host it on Deno Deploy as a106static site — they push the built \`dist/\` as the deploy root, which serves it as plain assets:107108- \`deno task deploy:create --org <org> --app <app>\` — one-time: create the app from \`dist/\`.109- \`deno task deploy\` — thereafter: build and push \`dist/\`.110111Point them at any other static host by editing those two tasks.112113Deployments use the origin root by default. If the host mounts the app below it, copy114\`.env.example\`, set \`LOFI_BASE_PATH=/my-app/\`, and rebuild. Upload \`dist/\` at that same path;115the manifest, public assets, service worker, worker scope, and preview URL all follow this value.116`;117}118119// The `@nzip/lofi/*` import keys the template carries as explicit local repo120// paths; a generated project resolves the same keys through the published121// package (or a file: override in clean-room tests). Everything else in the template deno.json122// — npm imports, tasks (including schema/migration/deploy), fmt, lint — is copied123// verbatim, so the template is the single source of truth for project config.124const lofiImportTargets: Record<string, { subpath: string; localPath: string }> = {125 "@nzip/lofi": { subpath: "", localPath: "runtime/mod.ts" },126 "@nzip/lofi/access": { subpath: "access", localPath: "access/mod.ts" },127 "@nzip/lofi/astro": { subpath: "astro", localPath: "astro/mod.ts" },128 "@nzip/lofi/build": { subpath: "build", localPath: "commands/build.ts" },129 "@nzip/lofi/dev": { subpath: "dev", localPath: "commands/dev.ts" },130 "@nzip/lofi/doctor": { subpath: "doctor", localPath: "commands/doctor.ts" },131 "@nzip/lofi/preview": { subpath: "preview", localPath: "commands/preview.ts" },132 "@nzip/lofi/preact": { subpath: "preact", localPath: "preact/mod.ts" },133 "@nzip/lofi/provision": { subpath: "provision", localPath: "commands/provision.ts" },134 "@nzip/lofi/recipes/file-handler": {135 subpath: "recipes/file-handler",136 localPath: "recipes/file-handler.ts",137 },138 "@nzip/lofi/recipes/launch-handler": {139 subpath: "recipes/launch-handler",140 localPath: "recipes/launch-handler.ts",141 },142 "@nzip/lofi/recipes/protocol-handler": {143 subpath: "recipes/protocol-handler",144 localPath: "recipes/protocol-handler.ts",145 },146 "@nzip/lofi/recipes/related-app-discovery": {147 subpath: "recipes/related-app-discovery",148 localPath: "recipes/related-app-discovery.ts",149 },150 "@nzip/lofi/recipes/scope-extension": {151 subpath: "recipes/scope-extension",152 localPath: "recipes/scope-extension.ts",153 },154 "@nzip/lofi/recipes/web-share": {155 subpath: "recipes/web-share",156 localPath: "recipes/web-share.ts",157 },158 "@nzip/lofi/recipes/window-controls-overlay": {159 subpath: "recipes/window-controls-overlay",160 localPath: "recipes/window-controls-overlay.ts",161 },162 "@nzip/lofi/schema": { subpath: "schema", localPath: "schema/mod.ts" },163 "@nzip/lofi/test": { subpath: "test", localPath: "commands/run_tests.ts" },164 "@nzip/lofi/testing": { subpath: "testing", localPath: "testing/mod.ts" },165};166167async function rewritePortableDenoConfig(root: string, packagePrefix: string): Promise<void> {168 const path = join(root, "deno.json");169 const config = JSON.parse(await Deno.readTextFile(path));170 const imports = config.imports as Record<string, string> | undefined;171 if (!imports || typeof imports["@nzip/lofi/"] !== "string") {172 throw new Error("starter deno.json is missing the @nzip/lofi/ import marker");173 }174 const isFileOverride = packagePrefix.startsWith("file:");175 imports["@nzip/lofi/"] = packagePrefix;176 for (const [key, { subpath, localPath }] of Object.entries(lofiImportTargets)) {177 if (typeof imports[key] !== "string") {178 throw new Error(`starter deno.json is missing the ${key} import`);179 }180 imports[key] = isFileOverride181 ? `${packagePrefix}${localPath}`182 : subpath183 ? `${packagePrefix}${subpath}`184 : packagePrefix.slice(0, -1);185 }186 // Never copy a Deno `links` override into a generated project. The reference187 // app uses explicit relative imports for its local package development loop.188 delete config.links;189 await Deno.writeTextFile(path, `${JSON.stringify(config, null, 2)}\n`);190}191192function validateName(name: string): string[] {193 if (name.trim() !== name || name.length === 0) {194 throw new Error("project name must be a non-empty relative path without surrounding spaces");195 }196 if (isAbsolute(name)) throw new Error("project name must be a relative path");197 const segments = name.split(/[\\/]/);198 if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {199 throw new Error("project name cannot contain empty, '.' or '..' path segments");200 }201 for (const segment of segments) {202 if (!/^[a-z0-9][a-z0-9._-]*$/i.test(segment)) {203 throw new Error(204 `project path segment ${205 JSON.stringify(segment)206 } must start with a letter or number and contain only letters, numbers, '.', '_' or '-'`,207 );208 }209 }210 return segments;211}212213async function directoryIsEmpty(path: string): Promise<boolean> {214 try {215 for await (const _entry of Deno.readDir(path)) return false;216 return true;217 } catch (error) {218 if (error instanceof Deno.errors.NotFound) return true;219 if (error instanceof Deno.errors.NotADirectory) return false;220 throw error;221 }222}223224async function assertNoSymlinkPath(cwd: string, segments: readonly string[]): Promise<void> {225 let path = cwd;226 for (const segment of segments) {227 path = join(path, segment);228 try {229 const stat = await Deno.lstat(path);230 if (stat.isSymlink) {231 throw new Error(232 `destination path crosses symbolic link ${233 JSON.stringify(relative(cwd, path))234 }; no files were changed. Choose a path without symbolic links`,235 );236 }237 } catch (error) {238 if (error instanceof Deno.errors.NotFound) return;239 throw error;240 }241 }242}243244async function writeTemplate(destination: string): Promise<void> {245 // Fetch concurrently: remote creation (jsr:) otherwise pays one network246 // round trip per starter file.247 const files = await Promise.all(STARTER_FILES.map(async (relativePath) => ({248 relativePath,249 content: await readStarterFile(relativePath),250 })));251 for (const { relativePath, content } of files) {252 const path = join(destination, relativePath);253 await Deno.mkdir(dirname(path), { recursive: true });254 await Deno.writeFile(path, content);255 }256}257258export async function createProject(options: CreateProjectOptions): Promise<CreateProjectResult> {259 const segments = validateName(options.name);260 const cwd = resolve(options.cwd);261 const destination = resolve(cwd, ...segments);262 const child = relative(cwd, destination);263 if (child.startsWith(`..${sep}`) || child === "..") {264 throw new Error("project destination must stay inside the current directory");265 }266 await assertNoSymlinkPath(cwd, segments);267268 if (!(await directoryIsEmpty(destination))) {269 throw new Error(270 `destination ${271 JSON.stringify(options.name)272 } already exists and is not empty; no files were changed. Choose a new name, for example: deno run -A jsr:@nzip/lofi/create ${273 basename(options.name)274 }-new`,275 );276 }277278 await Deno.mkdir(dirname(destination), { recursive: true });279 const staging = await Deno.makeTempDir({280 dir: dirname(destination),281 prefix: `.${basename(destination)}-lofi-create-`,282 });283 try {284 await writeTemplate(staging);285 await rewritePortableDenoConfig(staging, options.packagePrefix ?? LOFI_PACKAGE_PREFIX);286 await Deno.writeTextFile(join(staging, ".gitignore"), generatedGitignore);287 await Deno.writeTextFile(join(staging, ".env.example"), generatedEnvironment);288 await Deno.writeTextFile(join(staging, "README.md"), generatedReadme(basename(destination)));289290 await Deno.remove(destination).catch((error) => {291 if (!(error instanceof Deno.errors.NotFound)) throw error;292 });293 await Deno.rename(staging, destination);294 } catch (error) {295 await Deno.remove(staging, { recursive: true }).catch(() => undefined);296 throw error;297 }298299 return {300 destination,301 displayPath: options.name,302 nextCommands: [`cd ${options.name}`, "deno task dev"],303 };304}