Skip to content

CLI Scaffolding

Diátaxis type: Reference — information-oriented, lists all CLI commands with their options and generated output.

Table of Contents


Overview

Scratchy provides CLI commands to scaffold out common patterns, reducing boilerplate and ensuring consistency. Inspired by Laravel Artisan and RedwoodJS Generate.

Commands

scratchy make:model <Name>

Generates a Drizzle ORM schema file, queries, and mutations for a new database entity.

bash
pnpm scratchy make:model Post

Creates:

src/db/schema/post.ts          # Table definition with types and relations
src/db/queries/posts.ts        # Prepared statement queries
src/db/mutations/posts.ts      # CRUD mutation functions

Generated schema file:

typescript
// src/db/schema/post.ts
import { relations } from "drizzle-orm";
import { boolean, index, text } from "drizzle-orm/pg-core";
import { mySchema } from "~/db/my-schema.js";
import { timestamps } from "~/db/schema/columns.helpers.js";

// Types
export type Post = typeof post.$inferSelect;
export type NewPost = typeof post.$inferInsert;

// Table
export const post = mySchema.table(
  "post",
  {
    id: text().primaryKey(),
    ...timestamps,
  },
  (table) => [],
);

// Relations
export const postRelations = relations(post, ({ one, many }) => ({
  // Define relations here
}));

Generated queries file:

typescript
// src/db/queries/posts.ts
import { eq, sql } from "drizzle-orm";
import { db } from "~/db/index.js";
import { post } from "~/db/schema/post.js";

export const findPostById = db
  .select()
  .from(post)
  .where(eq(post.id, sql.placeholder("id")))
  .prepare("find_post_by_id");

export const findAllPosts = db.select().from(post).prepare("find_all_posts");

export type FindPostById = Awaited<ReturnType<typeof findPostById.execute>>;
export type FindAllPosts = Awaited<ReturnType<typeof findAllPosts.execute>>;

Generated mutations file:

typescript
// src/db/mutations/posts.ts
import { eq } from "drizzle-orm";
import { ulid } from "ulid";
import { db } from "~/db/index.js";
import { type NewPost, post } from "~/db/schema/post.js";

export async function createPost(data: Omit<NewPost, "id">) {
  const [result] = await db
    .insert(post)
    .values({ id: ulid(), ...data })
    .returning();
  return result;
}

export async function updatePost(id: string, data: Partial<NewPost>) {
  const [result] = await db
    .update(post)
    .set({ ...data, updatedAt: new Date() })
    .where(eq(post.id, id))
    .returning();
  return result;
}

export async function deletePost(id: string) {
  const [result] = await db.delete(post).where(eq(post.id, id)).returning();
  return result;
}

Options:

bash
pnpm scratchy make:model Post --columns "title:text,content:text,published:boolean"
pnpm scratchy make:model Post --with-router    # Also generates tRPC router

scratchy make:router <name>

Generates a tRPC router with queries and mutations.

bash
pnpm scratchy make:router posts

Creates:

src/routers/posts/queries.ts     # Query procedures
src/routers/posts/mutations.ts   # Mutation procedures

Generated queries file:

typescript
// src/routers/posts/queries.ts
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findAllPosts, findPostById } from "~/db/queries/posts.js";
import { protectedProcedure, publicProcedure } from "~/router.js";

export const postQueries = {
  getById: protectedProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input }) => {
      const [post] = await findPostById.execute({ id: input.id });
      if (!post) {
        throw new TRPCError({ code: "NOT_FOUND", message: "Post not found" });
      }
      return post;
    }),

  list: publicProcedure
    .input(
      z.object({
        page: z.number().min(1).default(1),
        limit: z.number().min(1).max(100).default(20),
      }),
    )
    .query(async ({ input }) => {
      const posts = await findAllPosts.execute();
      const start = (input.page - 1) * input.limit;
      return posts.slice(start, start + input.limit);
    }),
};

Generated mutations file:

typescript
// src/routers/posts/mutations.ts
import { z } from "zod";
import { createPost, deletePost, updatePost } from "~/db/mutations/posts.js";
import { protectedProcedure } from "~/router.js";

export const postMutations = {
  create: protectedProcedure
    .input(
      z.object({
        title: z.string().min(1).max(200),
      }),
    )
    .mutation(async ({ input }) => {
      return createPost(input);
    }),

  update: protectedProcedure
    .input(
      z.object({
        id: z.string(),
        title: z.string().min(1).max(200).optional(),
      }),
    )
    .mutation(async ({ input }) => {
      const { id, ...data } = input;
      return updatePost(id, data);
    }),

  delete: protectedProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ input }) => {
      await deletePost(input.id);
      return { success: true };
    }),
};

After generating, register the router in src/routers/index.ts:

typescript
import { postMutations } from "~/routers/posts/mutations.js";
import { postQueries } from "~/routers/posts/queries.js";

export const appRouter = router({
  // ... existing routers
  posts: router({
    ...postQueries,
    ...postMutations,
  }),
});

scratchy make:route <path>

Generates a Fastify REST route for external APIs.

bash
pnpm scratchy make:route /external/api/v1/products

Creates:

src/routes/external/api/v1/products/index.ts

Generated route file:

typescript
// src/routes/external/api/v1/products/index.ts
import type { FastifyPluginAsync } from "fastify";
import { z } from "zod";

const routes: FastifyPluginAsync = async function (fastify) {
  await fastify.register(import("@fastify/cors"), {
    origin: true,
    methods: ["GET", "POST", "PUT", "DELETE"],
  });

  fastify.get(
    "/",
    {
      schema: {
        querystring: z.object({
          page: z.coerce.number().min(1).default(1),
          limit: z.coerce.number().min(1).max(100).default(20),
        }),
      },
    },
    async (request, reply) => {
      // Implement list logic
      return { data: [], meta: { page: 1, limit: 20, total: 0 } };
    },
  );

  fastify.get(
    "/:id",
    {
      schema: {
        params: z.object({ id: z.string() }),
      },
    },
    async (request, reply) => {
      // Implement get by ID logic
      return { data: null };
    },
  );

  fastify.post(
    "/",
    {
      schema: {
        body: z.object({
          // Define input schema
        }),
      },
    },
    async (request, reply) => {
      // Implement create logic
      return reply.status(201).send({ data: null });
    },
  );
};

export default routes;

scratchy make:component <name>

Generates a Qwik component file.

bash
pnpm scratchy make:component user-profile

Creates:

src/client/components/qwik/user-profile.tsx

Generated component file:

typescript
// src/client/components/qwik/user-profile.tsx
import { component$ } from "@builder.io/qwik";

interface UserProfileProps {
  // Define props here
}

export const UserProfile = component$<UserProfileProps>((props) => {
  return (
    <div>
      <h2>UserProfile</h2>
    </div>
  );
});

Options:

bash
pnpm scratchy make:component chart --react    # Creates a React component with qwikify$
pnpm scratchy make:component hero --page      # Creates a page component in routes/

scratchy make:page <path>

Generates a Qwik page component with route loader.

bash
pnpm scratchy make:page blog/[slug]

Creates:

src/client/routes/blog/[slug]/index.tsx

Generated page file:

typescript
// src/client/routes/blog/[slug]/index.tsx
import { component$ } from "@builder.io/qwik";
import { routeLoader$ } from "@builder.io/qwik-city";

export const usePageData = routeLoader$(async ({ params, status }) => {
  const { slug } = params;
  // Fetch data for this page
  return { slug };
});

export default component$(() => {
  const data = usePageData();

  return (
    <div>
      <h1>Page: {data.value.slug}</h1>
    </div>
  );
});

scratchy make:plugin <name>

Generates a Fastify plugin file.

bash
pnpm scratchy make:plugin email-service

Creates:

src/plugins/app/email-service.ts

Generated plugin file:

typescript
// src/plugins/app/email-service.ts
import fp from "fastify-plugin";

export default fp(
  async function emailService(fastify) {
    // Initialize the plugin

    // Decorate fastify instance if needed
    // fastify.decorate("emailService", service);

    // Register cleanup on close
    fastify.addHook("onClose", async () => {
      // Cleanup resources
    });

    fastify.log.info("email-service plugin initialized");
  },
  {
    name: "email-service",
    // dependencies: ["database"],  // List plugin dependencies
  },
);

Full Scaffold

scratchy make:scaffold <Name>

Generates a complete feature scaffold including model, router, page, and component.

bash
pnpm scratchy make:scaffold Product

Creates:

src/db/schema/product.ts
src/db/queries/products.ts
src/db/mutations/products.ts
src/routers/products/queries.ts
src/routers/products/mutations.ts
src/client/routes/products/index.tsx
src/client/routes/products/[id]/index.tsx
src/client/components/qwik/product-card.tsx
src/client/components/qwik/product-form.tsx

Implementation Notes

The CLI tool should be implemented as a Node.js script using:

  • Commander.js or Citty for CLI argument parsing
  • Handlebars or EJS for template rendering
  • Inquirer or Prompts for interactive mode
  • Templates stored in the templates/ directory

Future Commands to Consider

bash
scratchy make:migration <name>     # Create an empty migration
scratchy make:middleware <name>     # Create a tRPC middleware
scratchy make:test <path>          # Generate a test file
scratchy make:seed <name>          # Create a database seeder
scratchy db:seed                   # Run all seeders
scratchy db:fresh                  # Drop and recreate database
scratchy routes:list               # List all registered routes
scratchy cache:clear               # Clear all caches