Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ coverage
# Build Outputs
.next/
out/
my-project/
build
dist
/.cache
Expand Down
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,19 @@
Refer to the README.md for information about this repository.

## Project Structure & Module Organization
`create/` contains the Node-based scaffold (`index.js` normalizes CLI options, `main.js` mutates the template). `template/` ships the starter monorepo with workspaces such as `api/` for backend endpoints, `mock-backend/` and `mock-data/` for fixtures, `prototype/` for the prototype app, `ui/` for shared components, and `docs/` for the Antora documentation site. Shared lint and TS configs live in `template/config/`, and bootstrap assets for generated apps sit under `template`.
`create/` contains the Node-based scaffold (`index.js` normalizes CLI options, `main.js` mutates the template). `template/` ships the default starter monorepo with workspaces such as `api/`, `mocks/`, `msw/`, `prototype/`, `ui/`, and `docs/`. `example-templates/` contains optional independent examples that can be copied into generated projects under `examples/<name>/`. Shared lint and TS configs live in `template/config/`.

## Build, Test & Development Commands
Use Node 22+. Run `npm install` at the repo root before touching the CLI. Template work happens inside `template/`: `npm install` for dependencies, `npm run dev` to launch Turbo-powered development, `npm run build` for a production check, `npm run typecheck` for repository-wide TypeScript validation, `npm run test` to execute Vitest suites, and `npm run format` to apply Prettier across Markdown and TypeScript files.

## Coding Style & Naming Conventions
Prettier enforces two-space indentation, single quotes, and trailing commas (`npm run format`). Keep imports auto-organized by the Prettier organize-imports plugin. Use `camelCase` for variables and functions, `PascalCase` for React components and types, and kebab-case for file names (e.g., `generate-data.mjs`). ESLint rules from `template/config/eslint/` run in every workspaceresolve warnings or document exceptions in-code.
Prettier enforces two-space indentation, single quotes, and trailing commas (`npm run format`). Keep imports auto-organized by the Prettier organize-imports plugin. Use `camelCase` for variables and functions, `PascalCase` for React components and types, and kebab-case for file names (e.g. `generate-data.mjs`). ESLint rules from `template/config/eslint/` run in every workspace, so resolve warnings or document exceptions in-code.

## Testing Guidelines
Vitest handles unit and integration coverage; colocate specs as `*.test.ts` or `*.spec.ts`. Run `npm run test` for the full suite, or target a package with `npm run test -- --filter=@repo/ui`. Always follow tests with `npm run typecheck` before opening a PR, and extend coverage around generators (`mock-data/cli`) and UI behavior when adding features.
Vitest handles unit and integration coverage; colocate specs as `*.test.ts` or `*.spec.ts`. Run `npm run test` for the full suite, or target a package with `npm run test -- --filter=@repo/ui`. Always follow tests with `npm run typecheck` before opening a PR. When changing the scaffold, cover both the default starter and any affected example templates.

## Commit & Pull Request Guidelines
Mirror the existing history by writing concise, imperative subjects (`add build verification for Github`). Group logically related changes per commit. Pull requests must include a summary, testing notes (`npm run build`, `npm run test`, etc.), linked issues when applicable, and screenshots for UI updates.

## Setup & Configuration Notes
The scaffold copies `.env.example` to `.env` and swaps `planning-stack-template` tokens; keep those references in sync when editing template assets. Honor the `SKIP_SETUP` and `SKIP_FORMAT` flags in `create/main.js` so automated flows remain consistent. Update every consumer (e.g., `template/gitlab-ci.yml`) when adjusting CI scaffolding.
The scaffold copies `.env.example` to `.env` and swaps `planning-stack-template` tokens in the base template. Honor the `SKIP_SETUP` and `SKIP_FORMAT` flags in `create/main.js` so automated flows remain consistent. Keep example templates independent from the base workspace so they can be deleted safely and are not built automatically by the root Turborepo configuration.
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
32 changes: 28 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,34 @@



## Local testing

To verify the generator before publishing:

1. Run `npm pack` in this repository to produce a tarball (for example `create-tinker-stack-0.2.6.tgz`).
2. In a temporary directory, execute `npx --yes create-tinker-stack@file://$(pwd)/create-tinker-stack-0.2.6.tgz`.
- Replace the path with the absolute path to the tarball from step 1 (npm `create` does not currently support `file:` specifiers).
1. Run `npm pack` in this repository to produce a tarball (for example `create-tinker-stack-0.2.8.tgz`).
2. Run the generator from this repository, passing a target directory as a positional argument:

```bash
npx --yes create-tinker-stack@file://$(pwd)/create-tinker-stack-0.2.8.tgz my-project
```

This scaffolds a clean starter into `./my-project`.

### With the example bundle

Pass `--with-example` to also generate the `react-router` example, or name a specific example with `--example`:

```bash
# Default example (react-router)
npx --yes create-tinker-stack@file://$(pwd)/create-tinker-stack-0.2.8.tgz my-project --with-example

# Named example
npx --yes create-tinker-stack@file://$(pwd)/create-tinker-stack-0.2.8.tgz my-project --example react-router
```

The example is placed in `my-project/examples/react-router/` and is independent of the root workspace.

### Notes

- `$(pwd)` must be run from the repository root where the tarball was produced.
- `npm create` does not currently support `file:` specifiers, so `npx` must be used.
- Omit the target directory argument to scaffold into the current working directory.
63 changes: 26 additions & 37 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,52 +1,41 @@
# Frontend Monorepo for Rapid Prototyping
# Tinker Stack

This is a npm Template for a Frontend Monorepo.
Tinker Stack is a project scaffold for frontend monorepos focused on rapid prototyping.

It puts emphasis on rapid prototyping and a prototype-driven development
([Pixar Planning](https://www.youtube.com/watch?v=gbuWJ48T0bE&t=1294s)).
By default it generates a clean starter workspace with:

The prototypes are using a virtual and ephemeral backend, so changes can be made very quickly without any need for
data migrations on schema changes.
- a minimal React Router prototype shell
- shared `api`, `ui`, `msw`, and `mocks` packages
- Turborepo, TypeScript, ESLint, Prettier, and Antora wiring
- every example template copied into `examples/<name>/`

Stakeholders can test a fully working UI before a single line of backend code has been written.
The scaffold no longer ships a demo application in the base workspace.

Test UI concepts on day 1, iterate on them with the designer on day 2 and present them to the customer on day 3.
## Create a project

The API to the backend has usually had several major iterations before a backend developer even gets involved.

## What is included?

This monorepo contains the following packages/apps:

### Apps and Packages
```bash
npm create tinker-stack@latest
```

- A prototype for the application, based on React Router SPA.
- A package that provides the domain types and enums that can be shared with the backend.
- A documentation in [Antora](https://antora.org/) (AsciiDoc) format.
- A package that provides synthetic data for the applications using
[Faker.js](https://fakerjs.dev/).
- A package that provides a mock API via service workers using [MSW](https://mswjs.io/).
- A component library that is shared by the main application and the prototype (still empty).
- ESLint and TypeScript configurations that are shared throughout the monorepo.
To generate the base workspace without any example folders:

### Features
```bash
npm create tinker-stack@latest -- --no-examples
```

- [TypeScript](https://www.typescriptlang.org/) Monorepo based on ESM standards.
- Turborepo setup for building and running the monorepo.
- Code formatting with [Prettier](https://prettier.io/) and linting with
[ESLint](https://eslint.org/).
To generate only specific example templates:

## Documentation
```bash
npm create tinker-stack@latest -- --example react-router
```

The project documentation is written in [AsciiDoc](https://asciidoctor.org/) and is generated using
[Antora](https://antora.org/).
By default, all available examples are copied into `examples/<name>/` inside the generated project.
They are self-contained and can be deleted without affecting the main workspace.

## Getting Started
## Example templates

To get started, run the following command:
The generator is designed for multiple named examples. Today the repository includes:

```bash
npm create tinker-stack@latest
```
- `react-router`

This will create a new project based on the Tinker Stack.
Each example is installed and run separately from its own directory. The root project does not add example folders to its npm workspaces, so Turborepo ignores them by default.
161 changes: 149 additions & 12 deletions create/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,47 +8,173 @@ import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const packageRoot = path.dirname(path.dirname(__filename));
const templateDir = path.join(packageRoot, 'template');
const exampleTemplatesDir = path.join(packageRoot, 'example-templates');
const DEFAULT_EXAMPLE = 'react-router';

function targetFromArgv() {
const argv = process.argv.slice(2);
// prefer a last non-flag positional argument as the target folder
for (let i = argv.length - 1; i >= 0; i--) {
if (!argv[i].startsWith('-')) return argv[i];
function parseCliArgs(argv = process.argv.slice(2)) {
const options = {
cwd: undefined,
debug: undefined,
install: undefined,
examples: [],
noExamples: false,
withExample: false
};
const positionals = [];

for (let i = 0; i < argv.length; i++) {
const arg = argv[i];

if (arg === '--example' || arg === '--examples') {
const value = argv[i + 1];
if (value && !value.startsWith('-')) {
options.examples.push(...splitExampleNames(value));
i += 1;
}
continue;
}

if (arg.startsWith('--example=')) {
options.examples.push(...splitExampleNames(arg.slice('--example='.length)));
continue;
}

if (arg.startsWith('--examples=')) {
options.examples.push(...splitExampleNames(arg.slice('--examples='.length)));
continue;
}

if (arg === '--with-example') {
options.withExample = true;
continue;
}

if (arg === '--no-examples') {
options.noExamples = true;
continue;
}

if (arg === '--cwd') {
const value = argv[i + 1];
if (value && !value.startsWith('-')) {
options.cwd = value;
i += 1;
}
continue;
}

if (arg.startsWith('--cwd=')) {
options.cwd = arg.slice('--cwd='.length);
continue;
}

if (arg === '--debug') {
options.debug = true;
continue;
}

if (arg === '--install') {
options.install = true;
continue;
}

if (arg === '--no-install') {
options.install = false;
continue;
}

if (!arg.startsWith('-')) {
positionals.push(arg);
}
}

return {
...options,
positionals
};
}

function splitExampleNames(value) {
return value
.split(',')
.map(example => example.trim())
.filter(Boolean);
}

function normalizeExampleNames(value) {
if (Array.isArray(value)) {
return value.flatMap(example => normalizeExampleNames(example));
}
return undefined;

if (typeof value === 'string') {
return splitExampleNames(value);
}

return [];
}

function normalizeOptions(input = {}) {
// input may be an options object passed by npm init or undefined
let opts = {};
if (typeof input === 'object' && input !== null) opts = input;
const cli = parseCliArgs();

const targetDir =
opts.targetDirectory ||
opts.name || // common field name for npm init
opts.project ||
targetFromArgv();
cli.positionals.at(-1);

const resolvedCwd =
typeof opts.cwd === 'string' && opts.cwd.length > 0
? path.resolve(process.cwd(), opts.cwd)
typeof (opts.cwd ?? cli.cwd) === 'string' && (opts.cwd ?? cli.cwd).length > 0
? path.resolve(process.cwd(), opts.cwd ?? cli.cwd)
: process.cwd();

const resolvedTargetDir =
typeof targetDir === 'string' && targetDir.length > 0
? path.resolve(resolvedCwd, targetDir)
: undefined;

const explicitExamples = [
...normalizeExampleNames(opts.examples),
...normalizeExampleNames(opts.example),
...normalizeExampleNames(cli.examples)
];

if (opts.withExample === true || cli.withExample) {
explicitExamples.push(DEFAULT_EXAMPLE);
}

const noExamples = Boolean(opts.noExamples ?? cli.noExamples);
const hasExplicitExamples = explicitExamples.length > 0;
const examples = hasExplicitExamples ? explicitExamples : noExamples ? [] : getDefaultExamples();
const normalizedExamples = [...new Set(examples)];

return {
...opts,
targetDir: resolvedTargetDir,
debug: !!opts.debug,
install: opts.install ?? true,
debug: Boolean(opts.debug ?? cli.debug),
install: opts.install ?? cli.install ?? true,
cwd: resolvedCwd,
templateDir // always use the built-in template
templateDir, // always use the built-in template
exampleTemplatesDir,
noExamples,
examples: normalizedExamples
};
}

function getDefaultExamples() {
try {
return fs
.readdirSync(exampleTemplatesDir, { withFileTypes: true })
.filter(entry => entry.isDirectory())
.map(entry => entry.name)
.sort();
} catch {
return [];
}
}

async function main(...args) {
const opts = normalizeOptions(args[0]);

Expand All @@ -62,6 +188,17 @@ async function main(...args) {
throw new Error(`Template directory not found: ${templateDir}\n${err.message}`);
}

if (opts.examples.length > 0) {
try {
const stat = fs.statSync(exampleTemplatesDir);
if (!stat.isDirectory()) {
throw new Error(`Example templates path is not a directory: ${exampleTemplatesDir}`);
}
} catch (err) {
throw new Error(`Example templates directory not found: ${exampleTemplatesDir}\n${err.message}`);
}
}

// call the implementation with simple, normalized options
await createMain(opts);
}
Expand Down
Loading
Loading