From 2202c5eca9c6c5a3b4829087fd7df7f61d55e8f2 Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:36:49 +0200 Subject: [PATCH] add init cli command --- .changeset/soft-clouds-init.md | 5 ++ README.md | 8 +-- docs/01-getting-started.md | 12 +++- docs/02-client.md | 2 +- docs/03-configure-and-query.md | 2 - packages/contrail/README.md | 6 +- packages/contrail/src/cli.ts | 2 + packages/contrail/src/cli/commands/init.ts | 47 ++++++++++++++ packages/contrail/tests/init.test.ts | 71 ++++++++++++++++++++++ 9 files changed, 144 insertions(+), 11 deletions(-) create mode 100644 .changeset/soft-clouds-init.md create mode 100644 packages/contrail/src/cli/commands/init.ts create mode 100644 packages/contrail/tests/init.test.ts diff --git a/.changeset/soft-clouds-init.md b/.changeset/soft-clouds-init.md new file mode 100644 index 0000000..f472ad6 --- /dev/null +++ b/.changeset/soft-clouds-init.md @@ -0,0 +1,5 @@ +--- +"@atmo-dev/contrail": minor +--- + +Add `contrail init [directory]` to create a ready-to-run starter `contrail.config.ts` without overwriting an existing config. diff --git a/README.md b/README.md index 21cd54a..639f67c 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Cloudflare Workers with D1 is the primary deployment target. Node.js with SQLite ## Documentation -1. [Get started locally](docs/00-getting-started.md) -2. [Add the typed client](docs/01-client.md) -3. [Configure and query](docs/02-configure-and-query.md) -4. [Deploy to Cloudflare Workers](docs/03-deploy-cloudflare.md) +1. [Get started locally](docs/01-getting-started.md) +2. [Add the typed client](docs/02-client.md) +3. [Configure and query](docs/03-configure-and-query.md) +4. [Deploy to Cloudflare Workers](docs/04-deploy-cloudflare.md) diff --git a/docs/01-getting-started.md b/docs/01-getting-started.md index 2e5cad0..fbdb23b 100644 --- a/docs/01-getting-started.md +++ b/docs/01-getting-started.md @@ -2,10 +2,16 @@ Run a local AppView for a public AT Protocol collection with Node.js 22.13 or newer. -Create an empty directory with one file: +Create a project with the starter config: + +```bash +pnpx @atmo-dev/contrail init my-appview +cd my-appview +``` + +This creates `contrail.config.ts`: ```ts -// contrail.config.ts export default { namespace: "com.example", collections: { @@ -29,4 +35,4 @@ Contrail resolves the Lexicons, backfills existing records, follows new records, curl 'http://127.0.0.1:8787/xrpc/com.example.event.listRecords?limit=10' ``` -Replace the collection and fields with your own. Next: [add the typed client to your app](./01-client.md). +Replace the collection and fields with your own. Next: [add the typed client to your app](./02-client.md). diff --git a/docs/02-client.md b/docs/02-client.md index e498f93..2aa477b 100644 --- a/docs/02-client.md +++ b/docs/02-client.md @@ -31,4 +31,4 @@ for (const event of response.data.records) { The method name, parameters, and response are all typed from the Lexicons. Re-run `connect` when the AppView config changes. -Next: [configure filters, sorting, and hydration](./02-configure-and-query.md). To use an existing deployed AppView, pass its HTTPS URL to `contrail connect` instead of a config path. +Next: [configure filters, sorting, and hydration](./03-configure-and-query.md). To use an existing deployed AppView, pass its HTTPS URL to `contrail connect` instead of a config path. diff --git a/docs/03-configure-and-query.md b/docs/03-configure-and-query.md index 2b5e27f..a5d3bae 100644 --- a/docs/03-configure-and-query.md +++ b/docs/03-configure-and-query.md @@ -79,5 +79,3 @@ Every record has `uri`, `cid`, and its original record body in `value`. Hydrated Pass the returned opaque `cursor` into the same query to get the next page. `limit` defaults to 50 and may be 1–200. Full-text `search` works with D1 and PostgreSQL. The zero-config local SQLite AppView does not provide full-text search. - -Next: [deploy to Cloudflare Workers](./03-deploy-cloudflare.md). diff --git a/packages/contrail/README.md b/packages/contrail/README.md index 1ad89cb..e30e3dd 100644 --- a/packages/contrail/README.md +++ b/packages/contrail/README.md @@ -166,12 +166,16 @@ Backups retain the database's change-log generation ID, positions, leases, boots ## Local development -A project containing only `contrail.config.ts` can start a complete local service: +Seed a new project with a basic `contrail.config.ts`, then start a complete local service: ```bash +contrail init my-appview +cd my-appview contrail dev ``` +Run `contrail init` without a directory to write the config in the current directory. The command refuses to replace a config in any standard auto-detected location. + Without a Wrangler config this creates or resumes `.contrail/dev.sqlite`, resolves missing configured record/ref Lexicons from the AT Protocol network without overwriting project-owned schemas, runs PDS backfill, serves the complete public Contrail discovery/XRPC/Lexicon surface at `http://127.0.0.1:8787`, and runs bounded Jetstream ingestion every minute. It never creates or changes a deployment provider lock. Add `.contrail/` to the project ignore file. Existing Wrangler projects retain the prior D1 development behavior automatically. Useful SQLite options include: ```bash diff --git a/packages/contrail/src/cli.ts b/packages/contrail/src/cli.ts index 2aa6d9a..101482b 100644 --- a/packages/contrail/src/cli.ts +++ b/packages/contrail/src/cli.ts @@ -12,9 +12,11 @@ import { registerAppendScheduled } from "./cli/commands/append-scheduled.js"; import { registerConnect } from "./cli/commands/connect.js"; import { registerLexicons } from "./cli/commands/lexicons.js"; import { registerChanges } from "./cli/commands/changes.js"; +import { registerInit } from "./cli/commands/init.js"; const cli = cac("contrail"); +registerInit(cli); registerBackfill(cli); registerDev(cli); registerAppendScheduled(cli); diff --git a/packages/contrail/src/cli/commands/init.ts b/packages/contrail/src/cli/commands/init.ts new file mode 100644 index 0000000..5d95e95 --- /dev/null +++ b/packages/contrail/src/cli/commands/init.ts @@ -0,0 +1,47 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join, relative, resolve } from "node:path"; +import type { CAC } from "cac"; +import { findConfigFile } from "../../cli-config.js"; + +export const STARTER_CONFIG = `export default { + namespace: "com.example", + collections: { + event: { + collection: "community.lexicon.calendar.event", + queryable: { startsAt: { type: "range" } }, + }, + }, +}; +`; + +/** Create a starter config without replacing an existing Contrail config. */ +export async function seedConfig(directory: string): Promise { + const root = resolve(directory); + await mkdir(root, { recursive: true }); + + const existing = findConfigFile(root); + if (existing) { + throw new Error(`A Contrail config already exists at ${existing}`); + } + + const path = join(root, "contrail.config.ts"); + try { + await writeFile(path, STARTER_CONFIG, { flag: "wx" }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + throw new Error(`A Contrail config already exists at ${path}`); + } + throw error; + } + return path; +} + +export function registerInit(cli: CAC): void { + cli + .command("init [directory]", "Create a starter contrail.config.ts") + .action(async (directory: string | undefined) => { + const path = await seedConfig(directory ?? process.cwd()); + const displayPath = relative(process.cwd(), path) || "contrail.config.ts"; + console.log(`Created ${displayPath}`); + }); +} diff --git a/packages/contrail/tests/init.test.ts b/packages/contrail/tests/init.test.ts new file mode 100644 index 0000000..c387344 --- /dev/null +++ b/packages/contrail/tests/init.test.ts @@ -0,0 +1,71 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { loadConfig } from "../src/cli-config"; +import { seedConfig, STARTER_CONFIG } from "../src/cli/commands/init"; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +async function temporaryRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), "contrail-init-")); + roots.push(root); + return root; +} + +describe("contrail init", () => { + it("creates a loadable starter config", async () => { + const root = await temporaryRoot(); + const path = await seedConfig(root); + + expect(path).toBe(join(root, "contrail.config.ts")); + expect(await readFile(path, "utf8")).toBe(STARTER_CONFIG); + expect(await loadConfig(path)).toEqual({ + namespace: "com.example", + collections: { + event: { + collection: "community.lexicon.calendar.event", + queryable: { startsAt: { type: "range" } }, + }, + }, + }); + }); + + it("creates a requested directory", async () => { + const root = await temporaryRoot(); + const nested = join(root, "my-appview"); + + await expect(seedConfig(nested)).resolves.toBe( + join(nested, "contrail.config.ts"), + ); + }); + + it("does not replace an existing config", async () => { + const root = await temporaryRoot(); + const path = join(root, "contrail.config.ts"); + const original = "export default { keep: true };\n"; + await writeFile(path, original); + + await expect(seedConfig(root)).rejects.toThrow( + `A Contrail config already exists at ${path}`, + ); + expect(await readFile(path, "utf8")).toBe(original); + }); + + it("does not add a second config when a discovered config exists", async () => { + const root = await temporaryRoot(); + const path = join(root, "src", "contrail.config.ts"); + await mkdir(join(root, "src")); + await writeFile(path, "export default {};\n"); + + await expect(seedConfig(root)).rejects.toThrow( + `A Contrail config already exists at ${path}`, + ); + }); +}); -- 2.51.2