clone

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

main
lofi / package / create_core.ts
create_core.ts 14.0K
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 local68account, restore it with a recoverable passkey, or use the portable 24-word recovery phrase69fallback. The account they already have carries over — electing to sync never changes its identity.70Passkey restore is scoped to the app's stable relying-party ID; provider syncing is not universally71portable. The framework 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) and79the80[access API reference](https://github.com/FelineStateMachine/lofi/blob/main/docs/reference/access.md).8182## Optional installed-app recipes8384OS-facing capabilities remain absent until the product opts in. Start with the85[installed-app recipe catalog](https://github.com/FelineStateMachine/lofi/blob/main/docs/recipes/README.md)86for tested manifest patches, feature detection, input validation, offline behavior, and normal-web87fallbacks. Isolated entrypoints cover Web Share, installed-window launch handling, and validated88file-import previews, custom protocol links, and presentation-only companion discovery without89adding any to the starter. Cross-origin app-window scope and desktop titlebar geometry remain90explicit experimental opt-ins.9192Public tasks: \`dev\`, \`doctor\`, \`test\`, \`build\`, and \`preview\`. Sync/backup and schema tasks:93\`jazz:provision\`, \`schema:validate\`, \`schema:deploy\`, \`migrations:create\`, and \`migrations:push\`.9495Start with the framework's96[generated-app guide](https://github.com/FelineStateMachine/lofi/blob/main/docs/getting-started.md)97when replacing the task example with your own schema, permissions, hook, and UI. The98[exact generated-project map](https://github.com/FelineStateMachine/lofi/blob/main/docs/reference/project-layout.md)99lists every source-controlled path and its ownership.100101Runtime, PWA, identity, sync, diagnostics, and Astro/Vite integration come from the one pinned102\`@nzip/lofi\` package version. They are not copied into this project's source.103104## Hosting105106\`deno task build\` emits a static PWA in \`dist/\`. The deploy tasks host it on Deno Deploy as a static107site — they push the built \`dist/\` as the deploy root, which serves it as plain assets:108109- \`deno task deploy:create --org <org> --app <app>\` — one-time: create the app from \`dist/\`.110- \`deno task deploy\` — thereafter: build and push \`dist/\`.111112Point them at any other static host by editing those two tasks.113114Deployments use the origin root by default. If the host mounts the app below it, copy115\`.env.example\`, set \`LOFI_BASE_PATH=/my-app/\`, and rebuild. Upload \`dist/\` at that same path; the116manifest, public assets, service worker, worker scope, and preview URL all follow this value.117`;118}119120// The `@nzip/lofi/*` import keys the template carries as explicit local repo121// paths; a generated project resolves the same keys through the published122// package (or a file: override in clean-room tests). Everything else in the template deno.json123// — npm imports, tasks (including schema/migration/deploy), fmt, lint — is copied124// verbatim, so the template is the single source of truth for project config.125const lofiImportTargets: Record<string, { subpath: string; localPath: string }> = {126 "@nzip/lofi": { subpath: "", localPath: "runtime/mod.ts" },127 "@nzip/lofi/access": { subpath: "access", localPath: "access/mod.ts" },128 "@nzip/lofi/astro": { subpath: "astro", localPath: "astro/mod.ts" },129 "@nzip/lofi/build": { subpath: "build", localPath: "commands/build.ts" },130 "@nzip/lofi/dev": { subpath: "dev", localPath: "commands/dev.ts" },131 "@nzip/lofi/doctor": { subpath: "doctor", localPath: "commands/doctor.ts" },132 "@nzip/lofi/preview": { subpath: "preview", localPath: "commands/preview.ts" },133 "@nzip/lofi/preact": { subpath: "preact", localPath: "preact/mod.ts" },134 "@nzip/lofi/provision": { subpath: "provision", localPath: "commands/provision.ts" },135 "@nzip/lofi/recipes/file-handler": {136 subpath: "recipes/file-handler",137 localPath: "recipes/file-handler.ts",138 },139 "@nzip/lofi/recipes/launch-handler": {140 subpath: "recipes/launch-handler",141 localPath: "recipes/launch-handler.ts",142 },143 "@nzip/lofi/recipes/protocol-handler": {144 subpath: "recipes/protocol-handler",145 localPath: "recipes/protocol-handler.ts",146 },147 "@nzip/lofi/recipes/related-app-discovery": {148 subpath: "recipes/related-app-discovery",149 localPath: "recipes/related-app-discovery.ts",150 },151 "@nzip/lofi/recipes/scope-extension": {152 subpath: "recipes/scope-extension",153 localPath: "recipes/scope-extension.ts",154 },155 "@nzip/lofi/recipes/web-share": {156 subpath: "recipes/web-share",157 localPath: "recipes/web-share.ts",158 },159 "@nzip/lofi/recipes/window-controls-overlay": {160 subpath: "recipes/window-controls-overlay",161 localPath: "recipes/window-controls-overlay.ts",162 },163 "@nzip/lofi/schema": { subpath: "schema", localPath: "schema/mod.ts" },164 "@nzip/lofi/test": { subpath: "test", localPath: "commands/run_tests.ts" },165 "@nzip/lofi/testing": { subpath: "testing", localPath: "testing/mod.ts" },166};167168async function rewritePortableDenoConfig(root: string, packagePrefix: string): Promise<void> {169 const path = join(root, "deno.json");170 const config = JSON.parse(await Deno.readTextFile(path));171 const imports = config.imports as Record<string, string> | undefined;172 if (!imports || typeof imports["@nzip/lofi/"] !== "string") {173 throw new Error("starter deno.json is missing the @nzip/lofi/ import marker");174 }175 const isFileOverride = packagePrefix.startsWith("file:");176 imports["@nzip/lofi/"] = packagePrefix;177 for (const [key, { subpath, localPath }] of Object.entries(lofiImportTargets)) {178 if (typeof imports[key] !== "string") {179 throw new Error(`starter deno.json is missing the ${key} import`);180 }181 imports[key] = isFileOverride182 ? `${packagePrefix}${localPath}`183 : subpath184 ? `${packagePrefix}${subpath}`185 : packagePrefix.slice(0, -1);186 }187 // Never copy a Deno `links` override into a generated project. The reference188 // app uses explicit relative imports for its local package development loop.189 delete config.links;190 await Deno.writeTextFile(path, `${JSON.stringify(config, null, 2)}\n`);191}192193function validateName(name: string): string[] {194 if (name.trim() !== name || name.length === 0) {195 throw new Error("project name must be a non-empty relative path without surrounding spaces");196 }197 if (isAbsolute(name)) throw new Error("project name must be a relative path");198 const segments = name.split(/[\\/]/);199 if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {200 throw new Error("project name cannot contain empty, '.' or '..' path segments");201 }202 for (const segment of segments) {203 if (!/^[a-z0-9][a-z0-9._-]*$/i.test(segment)) {204 throw new Error(205 `project path segment ${206 JSON.stringify(segment)207 } must start with a letter or number and contain only letters, numbers, '.', '_' or '-'`,208 );209 }210 }211 return segments;212}213214async function directoryIsEmpty(path: string): Promise<boolean> {215 try {216 for await (const _entry of Deno.readDir(path)) return false;217 return true;218 } catch (error) {219 if (error instanceof Deno.errors.NotFound) return true;220 if (error instanceof Deno.errors.NotADirectory) return false;221 throw error;222 }223}224225async function assertNoSymlinkPath(cwd: string, segments: readonly string[]): Promise<void> {226 let path = cwd;227 for (const segment of segments) {228 path = join(path, segment);229 try {230 const stat = await Deno.lstat(path);231 if (stat.isSymlink) {232 throw new Error(233 `destination path crosses symbolic link ${234 JSON.stringify(relative(cwd, path))235 }; no files were changed. Choose a path without symbolic links`,236 );237 }238 } catch (error) {239 if (error instanceof Deno.errors.NotFound) return;240 throw error;241 }242 }243}244245async function rewriteStarterFile(246 root: string,247 relativePath: string,248 edits: ReadonlyArray<readonly [marker: string, replacement: string]>,249): Promise<void> {250 const path = join(root, relativePath);251 let content = await Deno.readTextFile(path);252 for (const [marker, replacement] of edits) {253 if (!content.includes(marker)) {254 throw new Error(255 `starter ${relativePath} is missing the ${JSON.stringify(marker)} marker`,256 );257 }258 content = content.replaceAll(marker, replacement);259 }260 await Deno.writeTextFile(path, content);261}262263// The starter is a copy of the reference app, so the identity it carries — app264// and database names, install manifest, repository link — belongs to lofi, not265// to the new project. Rewrite that identity to the requested name and drop the266// repository link; the index page only renders its footer link when267// `repositoryUrl` is present.268async function personalizeStarter(root: string, name: string): Promise<void> {269 const quoted = JSON.stringify(name);270 await rewriteStarterFile(root, join("src", "app.ts"), [271 ['name: "lofi-prototype"', `name: ${quoted}`],272 ['databaseName: "lofi-prototype"', `databaseName: ${quoted}`],273 ['\n repositoryUrl: "https://github.com/FelineStateMachine/lofi",', ""],274 ]);275 await rewriteStarterFile(root, join("src", "pages", "index.astro"), [276 ['<Shell title="lofi starter">', `<Shell title=${quoted}>`],277 ['<p class="eyebrow">lofi</p>', `<p class="eyebrow">${name}</p>`],278 ]);279 const manifestPath = join(root, "public", "manifest.webmanifest");280 const manifest = JSON.parse(await Deno.readTextFile(manifestPath));281 manifest.name = name;282 manifest.short_name = name;283 manifest.id = `./${name}`;284 await Deno.writeTextFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);285}286287async function writeTemplate(destination: string): Promise<void> {288 // Fetch concurrently: remote creation (jsr:) otherwise pays one network289 // round trip per starter file.290 const files = await Promise.all(STARTER_FILES.map(async (relativePath) => ({291 relativePath,292 content: await readStarterFile(relativePath),293 })));294 for (const { relativePath, content } of files) {295 const path = join(destination, relativePath);296 await Deno.mkdir(dirname(path), { recursive: true });297 await Deno.writeFile(path, content);298 }299}300301export async function createProject(options: CreateProjectOptions): Promise<CreateProjectResult> {302 const segments = validateName(options.name);303 const cwd = resolve(options.cwd);304 const destination = resolve(cwd, ...segments);305 const child = relative(cwd, destination);306 if (child.startsWith(`..${sep}`) || child === "..") {307 throw new Error("project destination must stay inside the current directory");308 }309 await assertNoSymlinkPath(cwd, segments);310311 if (!(await directoryIsEmpty(destination))) {312 throw new Error(313 `destination ${314 JSON.stringify(options.name)315 } already exists and is not empty; no files were changed. Choose a new name, for example: deno run -A jsr:@nzip/lofi/create ${316 basename(options.name)317 }-new`,318 );319 }320321 await Deno.mkdir(dirname(destination), { recursive: true });322 const staging = await Deno.makeTempDir({323 dir: dirname(destination),324 prefix: `.${basename(destination)}-lofi-create-`,325 });326 try {327 await writeTemplate(staging);328 await personalizeStarter(staging, basename(destination));329 await rewritePortableDenoConfig(staging, options.packagePrefix ?? LOFI_PACKAGE_PREFIX);330 await Deno.writeTextFile(join(staging, ".gitignore"), generatedGitignore);331 await Deno.writeTextFile(join(staging, ".env.example"), generatedEnvironment);332 await Deno.writeTextFile(join(staging, "README.md"), generatedReadme(basename(destination)));333334 await Deno.remove(destination).catch((error) => {335 if (!(error instanceof Deno.errors.NotFound)) throw error;336 });337 await Deno.rename(staging, destination);338 } catch (error) {339 await Deno.remove(staging, { recursive: true }).catch(() => undefined);340 throw error;341 }342343 return {344 destination,345 displayPath: options.name,346 nextCommands: [`cd ${options.name}`, "deno task dev"],347 };348}