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
27 changes: 0 additions & 27 deletions .github/workflows/build-verification.yml

This file was deleted.

7 changes: 5 additions & 2 deletions .github/workflows/verify-generator.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,12 @@ jobs:
pwd
CURRENT_DIR=$(pwd)
TEMP_DIR=$(mktemp -d)
npm ci
npm link
cd $TEMP_DIR
npx create-remix@latest --template $CURRENT_DIR --debug --install --init-script --no-git-init ./my-remix-app
cd my-remix-app
npx create-tinker-stack ./my-app

cd my-app
npm run typecheck
cd $CURRENT_DIR
rm -rf $TEMP_DIR
21 changes: 21 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Repository Guidelines

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 Remix reference 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/remix.init/`.

## 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 workspace—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.

## 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.
6 changes: 2 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
# Frontend Monorepo for Rapid Prototyping

This is a [Create-Remix Template](https://remix.run/docs/en/main/guides/templates) for a Frontend
Monorepo. (Create-Remix is only used to generate the initial project structure, the project itself
does not use Remix.)
This is a [npm Template](https://remix.run/docs/en/main/guides/templates) for a Frontend Monorepo.

It puts emphasis on rapid prototyping and a prototype-driven development
([Pixar Planning](https://www.youtube.com/watch?v=gbuWJ48T0bE&t=1294s)).
Expand Down Expand Up @@ -50,5 +48,5 @@ The project documentation is written in [AsciiDoc](https://asciidoctor.org/) and
To get started, run the following command:

```bash
npx create-remix@latest --template ti8m/tinker-stack
npm create tinker-stack
```
59 changes: 59 additions & 0 deletions create/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#! /usr/bin/env node

import { main as createMain } from './main.js';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const __filename = fileURLToPath(import.meta.url);
const cwd = path.dirname(path.dirname(__filename));
const templateDir = path.join(cwd, 'template');

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];
}
return undefined;
}

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 targetDir =
opts.targetDirectory ||
opts.name || // common field name for npm init
opts.project ||
targetFromArgv();

return {
cwd,
templateDir: templateDir, // always use the built-in template
targetDir,
debug: !!opts.debug,
install: opts.install ?? true,
...opts
};
}

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

// validate template exists
try {
const stat = fs.statSync(templateDir);
if (!stat.isDirectory()) {
throw new Error(`Template path is not a directory: ${templateDir}`);
}
} catch (err) {
throw new Error(`Template directory not found: ${templateDir}\n${err.message}`);
}

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

main().catch(console.error.bind(console));
136 changes: 136 additions & 0 deletions create/main.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import inquirer from 'inquirer';
import { execSync } from 'node:child_process';
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';

const getRandomString = length => crypto.randomBytes(length).toString('hex');

export async function main({ cwd, templateDir, targetDir }) {
const APP_TITLE = await getTitle();

const APP_NAME = (APP_TITLE)
// get rid of anything that's not allowed in an app name
.replace(/[^a-zA-Z0-9-_]/g, '-')
.toLowerCase();

// Use targetDir if it is set, otherwise create it from app-name
targetDir = targetDir ? targetDir : path.join(cwd, APP_NAME);

console.log({ cwd, templateDir, targetDir, APP_NAME, APP_TITLE });

await fs.cp(templateDir, targetDir, { recursive: true, force: false, errorOnExist: true }, (err) => {
console.warn(err);
});

const EXAMPLE_ENV_PATH = path.join(targetDir, '.env.example');
const ENV_PATH = path.join(targetDir, '.env');
const PKG_PATH = path.join(targetDir, 'package.json');

const appNameRegex = /planning-stack-template/g;
const appTitleRegex = /PLANNING STACK TEMPLATE/g;

const [env, packageJsonString] = await Promise.all([
fs.readFile(EXAMPLE_ENV_PATH, 'utf-8'),
fs.readFile(PKG_PATH, 'utf-8'),
]);

const filesWithAppName = await Promise.all([
PKG_PATH,
path.join(targetDir, 'README.md'),
path.join(targetDir, 'docs', 'antora-playbook.yml'),
path.join(targetDir, 'docs', 'antora.yml'),
path.join(targetDir, 'docs', 'modules/ROOT/pages/architecture.adoc'),
path.join(targetDir, 'docs', 'modules/ROOT/pages/documentation.adoc'),
path.join(targetDir, 'docs', 'modules/ROOT/pages/getting-started.adoc'),
path.join(targetDir, 'mock-data', 'cli', 'generate-data.mjs'),
path.join(targetDir, 'prototype', 'README.md'),
path.join(targetDir, 'ui', 'README.md'),
]);

const filesWithAppTitle = await Promise.all([
path.join(targetDir, 'README.md'),
path.join(targetDir, 'docs', 'antora-playbook.yml'),
path.join(targetDir, 'docs', 'antora.yml'),
path.join(targetDir, 'docs', 'modules', 'ROOT', 'pages', 'architecture.adoc'),
path.join(targetDir, 'docs', 'modules', 'ROOT', 'pages', 'index.adoc'),
path.join(targetDir, 'docs', 'modules', 'ROOT', 'pages', 'monorepo.adoc'),
path.join(targetDir, 'docs', 'modules', 'ROOT', 'pages', 'prototype.adoc'),
path.join(targetDir, 'docs', 'modules', 'ROOT', 'partials', 'monorepo.puml'),
path.join(targetDir, 'docs', 'modules', 'ROOT', 'nav.adoc'),
path.join(targetDir, 'docs', 'antora-playbook.yml'),
path.join(targetDir, 'docs', 'package.json'),
path.join(targetDir, 'prototype', 'app', 'root.tsx'),
path.join(targetDir, 'prototype', 'app', 'routes', '_index.tsx'),
path.join(targetDir, 'mock-data', 'cli', 'generate-data.mjs'),
]);

// Replace all instances of the app title
for (const file of filesWithAppTitle) {
const fileContent = await fs.readFile(file, 'utf-8');
const newFile = fileContent.replaceAll(appTitleRegex, APP_TITLE);
await fs.writeFile(file, newFile);
}
// Replace all instances of the app name
for (const file of filesWithAppName) {
const fileContent = await fs.readFile(file, 'utf-8');
const newFile = fileContent.replaceAll(appNameRegex, APP_NAME);
await fs.writeFile(file, newFile);
}

const packageJson = JSON.parse(packageJsonString);

packageJson.name = APP_NAME;
delete packageJson.author;
delete packageJson.license;

const fileOperationPromises = [
fs.copyFile(EXAMPLE_ENV_PATH, ENV_PATH),
fs.writeFile(PKG_PATH, JSON.stringify(packageJson, null, 2)),
];

await Promise.all(fileOperationPromises);

if (!process.env.SKIP_SETUP) {
execSync('npm install', { cwd: targetDir, stdio: 'inherit' });
execSync('npm run typecheck', { cwd: targetDir, stdio: 'inherit' });
execSync('npm run build:data', { cwd: targetDir, stdio: 'inherit' });
}

if (!process.env.SKIP_FORMAT) {
execSync('npm run format -- --log-level warn', {
cwd: targetDir,
stdio: 'inherit',
});
}

console.log(
`
Setup is complete.

What's next?

- Build your mock data
- Build your mock API
- Build your prototype
- Iterate
`.trim(),
);
}

async function getTitle() {
// Check if we are in interactive mode
if (process.env.CI) {
return 'Demo Title';
}

const { title } = await inquirer.prompt([
{
type: 'input',
name: 'title',
message: 'Enter the title of the app',
},
]);

return title;
}
Loading