@dahoolabs/create 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -2
- package/package.json +35 -4
- package/src/index.ts +70 -0
- package/src/options.ts +49 -0
- package/src/prompts.ts +48 -0
- package/src/registry.ts +30 -0
- package/src/scaffold.ts +57 -0
- package/templates/bun-nest-next/.editorconfig +12 -0
- package/templates/bun-nest-next/.env.example +6 -0
- package/templates/bun-nest-next/.github/workflows/ci.yml +47 -0
- package/templates/bun-nest-next/.github/workflows/release-please.yml +18 -0
- package/templates/bun-nest-next/.husky/commit-msg +1 -0
- package/templates/bun-nest-next/.husky/pre-commit +2 -0
- package/templates/bun-nest-next/.release-please-manifest.json +3 -0
- package/templates/bun-nest-next/AGENT.md +49 -0
- package/templates/bun-nest-next/README.md +21 -0
- package/templates/bun-nest-next/_gitignore +15 -0
- package/templates/bun-nest-next/apps/api/package.json +29 -0
- package/templates/bun-nest-next/apps/api/src/app.module.ts +7 -0
- package/templates/bun-nest-next/apps/api/src/health/health.controller.test.ts +19 -0
- package/templates/bun-nest-next/apps/api/src/health/health.controller.ts +17 -0
- package/templates/bun-nest-next/apps/api/src/health/health.module.ts +7 -0
- package/templates/bun-nest-next/apps/api/src/main.ts +21 -0
- package/templates/bun-nest-next/apps/api/tsconfig.json +10 -0
- package/templates/bun-nest-next/apps/web/app/globals.css +1 -0
- package/templates/bun-nest-next/apps/web/app/layout.tsx +17 -0
- package/templates/bun-nest-next/apps/web/app/page.tsx +25 -0
- package/templates/bun-nest-next/apps/web/next.config.ts +7 -0
- package/templates/bun-nest-next/apps/web/package.json +28 -0
- package/templates/bun-nest-next/apps/web/postcss.config.mjs +5 -0
- package/templates/bun-nest-next/apps/web/tsconfig.json +16 -0
- package/templates/bun-nest-next/commitlint.config.ts +6 -0
- package/templates/bun-nest-next/e2e/api.spec.ts +7 -0
- package/templates/bun-nest-next/e2e/home.spec.ts +6 -0
- package/templates/bun-nest-next/package.json +38 -0
- package/templates/bun-nest-next/packages/shared/package.json +17 -0
- package/templates/bun-nest-next/packages/shared/src/index.ts +5 -0
- package/templates/bun-nest-next/packages/shared/tsconfig.json +7 -0
- package/templates/bun-nest-next/playwright.config.ts +28 -0
- package/templates/bun-nest-next/release-please-config.json +10 -0
- package/templates/bun-nest-next/tsconfig.base.json +16 -0
- package/templates/bun-nest-next/tsconfig.json +7 -0
- package/templates/bun-nest-next/turbo.json +25 -0
package/README.md
CHANGED
|
@@ -1,3 +1,46 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @dahoolabs/create
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Scaffold a new Dahoo Labs project.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
bunx @dahoolabs/create my-app
|
|
7
|
+
# or
|
|
8
|
+
bun create @dahoolabs my-app
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Options
|
|
12
|
+
| Flag | Description |
|
|
13
|
+
|---|---|
|
|
14
|
+
| `-t, --template <id>` | Template to use (default `bun-nest-next`) |
|
|
15
|
+
| `--no-install` | Skip `bun install` |
|
|
16
|
+
| `--no-git` | Skip `git init` and the initial commit |
|
|
17
|
+
| `-f, --force` | Allow a non-empty target directory |
|
|
18
|
+
| `-y, --yes` | Use defaults, no prompts |
|
|
19
|
+
|
|
20
|
+
## Templates
|
|
21
|
+
### `bun-nest-next`
|
|
22
|
+
A Bun workspace + Turborepo monorepo:
|
|
23
|
+
- `apps/api`: NestJS on Bun, with Swagger at `/docs` and a `GET /health` endpoint
|
|
24
|
+
- `apps/web`: Next.js (App Router) + Tailwind CSS v4
|
|
25
|
+
- `packages/shared`: types shared by both apps
|
|
26
|
+
|
|
27
|
+
Every project gets these defaults, and they can't be turned off:
|
|
28
|
+
- Turbo
|
|
29
|
+
- Husky: pre-commit runs check and test; commit-msg runs commitlint
|
|
30
|
+
- commitlint + Commitizen (`bun run commit`)
|
|
31
|
+
- Playwright e2e
|
|
32
|
+
- release-please
|
|
33
|
+
- GitHub Actions CI
|
|
34
|
+
- `AGENT.md`
|
|
35
|
+
|
|
36
|
+
## Developing
|
|
37
|
+
```sh
|
|
38
|
+
bun install
|
|
39
|
+
bun run test # unit tests for the CLI
|
|
40
|
+
bun src/index.ts ../../../scratch/demo --yes # try it locally
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Template files are copied as-is. `{{projectName}}` and `{{scope}}` are replaced, and `_gitignore` is renamed to `.gitignore`, because npm drops `.gitignore` from published packages.
|
|
44
|
+
|
|
45
|
+
## Releasing
|
|
46
|
+
Releases are automated with release-please. Merge conventional commits that touch `packages/create` into `main`, then merge the release PR. The workflow publishes to npm using the `NPM_TOKEN` secret.
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dahoolabs/create",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Scaffold a new Dahoo Labs project",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"create": "src/index.ts"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"!src/**/*.test.ts",
|
|
12
|
+
"templates"
|
|
13
|
+
],
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/dahoolabs/base.git",
|
|
17
|
+
"directory": "packages/create"
|
|
18
|
+
},
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"engines": {
|
|
21
|
+
"bun": ">=1.3.0"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"check": "bun check",
|
|
28
|
+
"test": "bun test ./src"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@clack/prompts": "^1.8.1"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/bun": "^1.4.3",
|
|
35
|
+
"typescript": "^6.0.3"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import * as p from "@clack/prompts";
|
|
3
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
4
|
+
import { relative, resolve } from "node:path";
|
|
5
|
+
import { parseOptions, USAGE } from "./options";
|
|
6
|
+
import { promptOptions } from "./prompts";
|
|
7
|
+
import { getTemplate } from "./registry";
|
|
8
|
+
import { scaffold, toPackageName } from "./scaffold";
|
|
9
|
+
|
|
10
|
+
async function run(cmd: string[], cwd: string): Promise<void> {
|
|
11
|
+
const proc = Bun.spawn(cmd, { cwd, stdout: "inherit", stderr: "inherit" });
|
|
12
|
+
const code = await proc.exited;
|
|
13
|
+
if (code !== 0) throw new Error(`\`${cmd.join(" ")}\` exited with code ${code}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function main(): Promise<void> {
|
|
17
|
+
const parsed = parseOptions(process.argv.slice(2));
|
|
18
|
+
if (parsed.help) {
|
|
19
|
+
console.log(USAGE);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
p.intro("Dahoo Labs · create");
|
|
24
|
+
const options = await promptOptions(parsed);
|
|
25
|
+
const template = getTemplate(options.template);
|
|
26
|
+
const targetDir = resolve(options.dir);
|
|
27
|
+
const projectName = toPackageName(targetDir);
|
|
28
|
+
|
|
29
|
+
if (existsSync(targetDir) && readdirSync(targetDir).length > 0 && !options.force) {
|
|
30
|
+
p.cancel(`${targetDir} is not empty. Use --force to scaffold into it anyway.`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const spinner = p.spinner();
|
|
35
|
+
spinner.start(`Scaffolding ${template.label}`);
|
|
36
|
+
await scaffold(template.dir, targetDir, { projectName, scope: projectName });
|
|
37
|
+
spinner.stop(`Scaffolded ${template.label} into ${targetDir}`);
|
|
38
|
+
|
|
39
|
+
// git must exist before install so husky's `prepare` script can register the hooks.
|
|
40
|
+
if (options.git) await run(["git", "init", "-q", "-b", "main"], targetDir);
|
|
41
|
+
|
|
42
|
+
if (options.install) {
|
|
43
|
+
p.log.step("Installing dependencies");
|
|
44
|
+
await run(["bun", "install"], targetDir);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (options.git) {
|
|
48
|
+
await run(["git", "add", "-A"], targetDir);
|
|
49
|
+
// Hooks need installed deps; skip them when the user opted out of installing.
|
|
50
|
+
const commit = ["git", "commit", "-q", "-m", "chore: initial scaffold"];
|
|
51
|
+
if (!options.install) commit.push("--no-verify");
|
|
52
|
+
p.log.step("Creating initial commit");
|
|
53
|
+
await run(commit, targetDir);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const cd = relative(process.cwd(), targetDir) || ".";
|
|
57
|
+
const steps = [
|
|
58
|
+
cd !== "." && `cd ${cd}`,
|
|
59
|
+
!options.install && "bun install",
|
|
60
|
+
"bun run e2e:install # once, downloads Playwright browsers",
|
|
61
|
+
"bun run dev",
|
|
62
|
+
].filter(Boolean);
|
|
63
|
+
p.note(steps.join("\n"), "Next steps");
|
|
64
|
+
p.outro("Happy building.");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
main().catch((error: unknown) => {
|
|
68
|
+
p.cancel(error instanceof Error ? error.message : String(error));
|
|
69
|
+
process.exit(1);
|
|
70
|
+
});
|
package/src/options.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
import { DEFAULT_TEMPLATE } from "./registry";
|
|
3
|
+
|
|
4
|
+
export interface Options {
|
|
5
|
+
dir?: string;
|
|
6
|
+
template: string;
|
|
7
|
+
install: boolean;
|
|
8
|
+
git: boolean;
|
|
9
|
+
force: boolean;
|
|
10
|
+
yes: boolean;
|
|
11
|
+
help: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const USAGE = `Usage: bunx @dahoolabs/create [dir] [options]
|
|
15
|
+
|
|
16
|
+
Options:
|
|
17
|
+
-t, --template <id> Template to use (default: ${DEFAULT_TEMPLATE})
|
|
18
|
+
--no-install Skip \`bun install\`
|
|
19
|
+
--no-git Skip \`git init\` and the initial commit
|
|
20
|
+
-f, --force Allow scaffolding into a non-empty directory
|
|
21
|
+
-y, --yes Accept defaults, no prompts
|
|
22
|
+
-h, --help Show this help
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
export function parseOptions(argv: string[]): Options {
|
|
26
|
+
const { values, positionals } = parseArgs({
|
|
27
|
+
args: argv,
|
|
28
|
+
allowPositionals: true,
|
|
29
|
+
allowNegative: true,
|
|
30
|
+
options: {
|
|
31
|
+
template: { type: "string", short: "t", default: DEFAULT_TEMPLATE },
|
|
32
|
+
install: { type: "boolean", default: true },
|
|
33
|
+
git: { type: "boolean", default: true },
|
|
34
|
+
force: { type: "boolean", short: "f", default: false },
|
|
35
|
+
yes: { type: "boolean", short: "y", default: false },
|
|
36
|
+
help: { type: "boolean", short: "h", default: false },
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
dir: positionals[0],
|
|
42
|
+
template: values.template,
|
|
43
|
+
install: values.install,
|
|
44
|
+
git: values.git,
|
|
45
|
+
force: values.force,
|
|
46
|
+
yes: values.yes,
|
|
47
|
+
help: values.help,
|
|
48
|
+
};
|
|
49
|
+
}
|
package/src/prompts.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import type { Options } from "./options";
|
|
3
|
+
import { TEMPLATES } from "./registry";
|
|
4
|
+
|
|
5
|
+
function bail(value: unknown): void {
|
|
6
|
+
if (p.isCancel(value)) {
|
|
7
|
+
p.cancel("Cancelled.");
|
|
8
|
+
process.exit(1);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Fills in anything not given on the command line. Returns options unchanged with --yes. */
|
|
13
|
+
export async function promptOptions(options: Options): Promise<Options & { dir: string }> {
|
|
14
|
+
if (options.yes) return { ...options, dir: options.dir ?? "my-app" };
|
|
15
|
+
|
|
16
|
+
const dir =
|
|
17
|
+
options.dir ??
|
|
18
|
+
(await p.text({
|
|
19
|
+
message: "Where should the project be created?",
|
|
20
|
+
placeholder: "my-app",
|
|
21
|
+
defaultValue: "my-app",
|
|
22
|
+
}));
|
|
23
|
+
bail(dir);
|
|
24
|
+
|
|
25
|
+
const template =
|
|
26
|
+
TEMPLATES.length === 1
|
|
27
|
+
? options.template
|
|
28
|
+
: await p.select({
|
|
29
|
+
message: "Which template?",
|
|
30
|
+
initialValue: options.template,
|
|
31
|
+
options: TEMPLATES.map((t) => ({ value: t.id, label: t.label, hint: t.hint })),
|
|
32
|
+
});
|
|
33
|
+
bail(template);
|
|
34
|
+
|
|
35
|
+
const install = options.install ? await p.confirm({ message: "Install dependencies?", initialValue: true }) : false;
|
|
36
|
+
bail(install);
|
|
37
|
+
|
|
38
|
+
const git = options.git ? await p.confirm({ message: "Initialize a git repository?", initialValue: true }) : false;
|
|
39
|
+
bail(git);
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
...options,
|
|
43
|
+
dir: dir as string,
|
|
44
|
+
template: template as string,
|
|
45
|
+
install: install as boolean,
|
|
46
|
+
git: git as boolean,
|
|
47
|
+
};
|
|
48
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
|
|
3
|
+
export interface Template {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
hint: string;
|
|
7
|
+
dir: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const TEMPLATES_DIR = join(import.meta.dir, "..", "templates");
|
|
11
|
+
|
|
12
|
+
export const TEMPLATES: Template[] = [
|
|
13
|
+
{
|
|
14
|
+
id: "bun-nest-next",
|
|
15
|
+
label: "Bun + NestJS + Next.js",
|
|
16
|
+
hint: "Turbo monorepo: apps/api (NestJS), apps/web (Next.js), packages/shared",
|
|
17
|
+
dir: join(TEMPLATES_DIR, "bun-nest-next"),
|
|
18
|
+
},
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_TEMPLATE = "bun-nest-next";
|
|
22
|
+
|
|
23
|
+
export function getTemplate(id: string): Template {
|
|
24
|
+
const template = TEMPLATES.find((t) => t.id === id);
|
|
25
|
+
if (!template) {
|
|
26
|
+
const ids = TEMPLATES.map((t) => t.id).join(", ");
|
|
27
|
+
throw new Error(`Unknown template "${id}". Available: ${ids}`);
|
|
28
|
+
}
|
|
29
|
+
return template;
|
|
30
|
+
}
|
package/src/scaffold.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { cp, readdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { basename, extname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface TemplateVars {
|
|
5
|
+
projectName: string;
|
|
6
|
+
scope: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// npm strips these from published tarballs, so templates store them with a leading underscore.
|
|
10
|
+
const RENAMES: Record<string, string> = {
|
|
11
|
+
_gitignore: ".gitignore",
|
|
12
|
+
_npmrc: ".npmrc",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const BINARY_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".ico", ".webp", ".woff", ".woff2"]);
|
|
16
|
+
|
|
17
|
+
/** Turns an arbitrary directory name into a valid npm package name. */
|
|
18
|
+
export function toPackageName(name: string): string {
|
|
19
|
+
return (
|
|
20
|
+
basename(name)
|
|
21
|
+
.trim()
|
|
22
|
+
.toLowerCase()
|
|
23
|
+
.replace(/[^a-z0-9-~]+/g, "-")
|
|
24
|
+
.replace(/^[-.~]+|-+$/g, "") || "app"
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function render(content: string, vars: TemplateVars): string {
|
|
29
|
+
return content.replace(/\{\{(\w+)\}\}/g, (match, key: string) =>
|
|
30
|
+
key in vars ? vars[key as keyof TemplateVars] : match,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function scaffold(templateDir: string, targetDir: string, vars: TemplateVars): Promise<void> {
|
|
35
|
+
await cp(templateDir, targetDir, { recursive: true, force: true });
|
|
36
|
+
await processDir(targetDir, vars);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function processDir(dir: string, vars: TemplateVars): Promise<void> {
|
|
40
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
41
|
+
let path = join(dir, entry.name);
|
|
42
|
+
const renamed = RENAMES[entry.name];
|
|
43
|
+
if (renamed) {
|
|
44
|
+
const next = join(dir, renamed);
|
|
45
|
+
await rename(path, next);
|
|
46
|
+
path = next;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (entry.isDirectory()) {
|
|
50
|
+
await processDir(path, vars);
|
|
51
|
+
} else if (!BINARY_EXTENSIONS.has(extname(path))) {
|
|
52
|
+
const content = await readFile(path, "utf8");
|
|
53
|
+
const output = render(content, vars);
|
|
54
|
+
if (output !== content) await writeFile(path, output);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
concurrency:
|
|
9
|
+
group: ci-${{ github.ref }}
|
|
10
|
+
cancel-in-progress: true
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
commitlint:
|
|
14
|
+
if: github.event_name == 'pull_request'
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
with:
|
|
19
|
+
fetch-depth: 0
|
|
20
|
+
- uses: oven-sh/setup-bun@v2
|
|
21
|
+
- run: bun install --frozen-lockfile
|
|
22
|
+
- run: bunx --bun commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose
|
|
23
|
+
|
|
24
|
+
verify:
|
|
25
|
+
runs-on: ubuntu-latest
|
|
26
|
+
steps:
|
|
27
|
+
- uses: actions/checkout@v4
|
|
28
|
+
- uses: oven-sh/setup-bun@v2
|
|
29
|
+
- run: bun install --frozen-lockfile
|
|
30
|
+
- run: bun run check
|
|
31
|
+
- run: bun run test
|
|
32
|
+
- run: bun run build
|
|
33
|
+
|
|
34
|
+
e2e:
|
|
35
|
+
runs-on: ubuntu-latest
|
|
36
|
+
steps:
|
|
37
|
+
- uses: actions/checkout@v4
|
|
38
|
+
- uses: oven-sh/setup-bun@v2
|
|
39
|
+
- run: bun install --frozen-lockfile
|
|
40
|
+
- run: bunx playwright install --with-deps chromium
|
|
41
|
+
- run: bun run e2e
|
|
42
|
+
- uses: actions/upload-artifact@v4
|
|
43
|
+
if: failure()
|
|
44
|
+
with:
|
|
45
|
+
name: playwright-report
|
|
46
|
+
path: playwright-report
|
|
47
|
+
retention-days: 7
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
name: Release Please
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: write
|
|
9
|
+
pull-requests: write
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
release-please:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- uses: googleapis/release-please-action@v4
|
|
16
|
+
with:
|
|
17
|
+
config-file: release-please-config.json
|
|
18
|
+
manifest-file: .release-please-manifest.json
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bunx --bun commitlint --edit "$1"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# AGENT.md
|
|
2
|
+
|
|
3
|
+
Guidance for AI coding agents (and humans) working in **{{projectName}}**.
|
|
4
|
+
|
|
5
|
+
## Stack
|
|
6
|
+
- **Runtime / package manager:** Bun (workspaces). Always use `bun` / `bunx`, never npm, yarn, or pnpm.
|
|
7
|
+
- **Monorepo:** Turborepo. Run tasks from the root; Turbo fans them out and caches results.
|
|
8
|
+
- **API:** NestJS on Bun, in `apps/api`. Swagger UI at `/docs`.
|
|
9
|
+
- **Web:** Next.js (App Router) + Tailwind CSS v4, in `apps/web`.
|
|
10
|
+
- **Shared:** `packages/shared` (`@{{scope}}/shared`), for types and contracts used by both apps.
|
|
11
|
+
- **Tests:** `bun test` for unit and integration tests, Playwright for e2e (`e2e/`).
|
|
12
|
+
- **Git:** Husky hooks, commitlint (Conventional Commits), Commitizen, release-please.
|
|
13
|
+
|
|
14
|
+
## Layout
|
|
15
|
+
```
|
|
16
|
+
apps/api/src/<feature>/ # one Nest module per feature: module, controller, service, dto, *.test.ts
|
|
17
|
+
apps/web/app/ # Next.js App Router routes
|
|
18
|
+
packages/shared/src/ # shared types; no runtime deps on either app
|
|
19
|
+
e2e/ # Playwright specs that run against the real api and web
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Commands
|
|
23
|
+
| Task | Command |
|
|
24
|
+
|---|---|
|
|
25
|
+
| Dev servers (api :3001, web :3000) | `bun run dev` |
|
|
26
|
+
| Type check (the static gate; no separate linter) | `bun run check` |
|
|
27
|
+
| Unit tests | `bun run test` |
|
|
28
|
+
| E2E tests (first run `bun run e2e:install`) | `bun run e2e` |
|
|
29
|
+
| Production build | `bun run build` |
|
|
30
|
+
| Commit with a guided prompt | `bun run commit` |
|
|
31
|
+
| Add a dependency to one workspace | `bun add <pkg> --cwd apps/api` |
|
|
32
|
+
|
|
33
|
+
## Rules
|
|
34
|
+
1. **Conventional Commits are enforced.** Use `type(scope): subject`. Types: `feat`, `fix`, `chore`, `docs`, `refactor`, `test`, `perf`, `ci`, `build`, `style`, `revert`. Scopes: `api`, `web`, `shared`, `e2e`, `deps`, `ci`, `release`. The commit-msg hook rejects anything else. Never bypass hooks with `--no-verify`.
|
|
35
|
+
2. **`bun run check` and `bun run test` must pass before committing.** The pre-commit hook runs both.
|
|
36
|
+
3. **Do not edit `CHANGELOG.md` or bump versions by hand.** release-please does this from commit history. `feat` → minor, `fix` → patch, `feat!` / `BREAKING CHANGE:` → major.
|
|
37
|
+
4. **Keep the API RESTful and documented.** Every endpoint gets Swagger decorators (`@ApiTags`, `@ApiOkResponse`, …) and DTOs validated with `class-validator`.
|
|
38
|
+
5. **Layered clean architecture in the API.** Controllers handle HTTP only, services hold business logic, and data access stays behind a repository/provider. Don't put logic in controllers.
|
|
39
|
+
6. **Shared contracts live in `packages/shared`.** If both apps need a type, define it there instead of duplicating it.
|
|
40
|
+
7. **Test at the right level.** Unit-test services, integration-test modules with `@nestjs/testing`, and add a Playwright spec for every user-facing flow.
|
|
41
|
+
8. **Small, focused changes.** One concern per PR/commit. Don't refactor unrelated code in passing.
|
|
42
|
+
9. **No secrets in git.** Put new env vars in `.env.example` with a safe default.
|
|
43
|
+
|
|
44
|
+
## Principles & references
|
|
45
|
+
These are the Dahoo Labs engineering principles. Read them before making architectural decisions:
|
|
46
|
+
- [ENGINEERING.md](https://github.com/dahoolabs/base/blob/main/ENGINEERING.md): API design, observability, resilience, scaling, architecture, testing
|
|
47
|
+
- [BACKEND.md](https://github.com/dahoolabs/base/blob/main/BACKEND.md)
|
|
48
|
+
- [FRONTEND.md](https://github.com/dahoolabs/base/blob/main/FRONTEND.md)
|
|
49
|
+
- [AGENTIC_SWE.md](https://github.com/dahoolabs/base/blob/main/AGENTIC_SWE.md)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# {{projectName}}
|
|
2
|
+
|
|
3
|
+
Scaffolded with [`@dahoolabs/create`](https://github.com/dahoolabs/base/tree/main/packages/create).
|
|
4
|
+
|
|
5
|
+
## Getting started
|
|
6
|
+
```sh
|
|
7
|
+
bun install
|
|
8
|
+
cp .env.example .env
|
|
9
|
+
bun run e2e:install # once, downloads Playwright browsers
|
|
10
|
+
bun run dev # api on :3001 (docs at /docs), web on :3000
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Scripts
|
|
14
|
+
- `bun run dev`: start api and web in watch mode
|
|
15
|
+
- `bun run check`: type check every workspace
|
|
16
|
+
- `bun run test`: unit tests
|
|
17
|
+
- `bun run e2e`: Playwright end-to-end tests
|
|
18
|
+
- `bun run build`: production build
|
|
19
|
+
- `bun run commit`: guided Conventional Commit
|
|
20
|
+
|
|
21
|
+
See [AGENT.md](./AGENT.md) for conventions.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@{{scope}}/api",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "bun --watch src/main.ts",
|
|
8
|
+
"build": "bun build src/main.ts --target bun --packages external --outdir dist --sourcemap=linked",
|
|
9
|
+
"start": "bun dist/main.js",
|
|
10
|
+
"check": "bun check",
|
|
11
|
+
"test": "bun test ./src"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@{{scope}}/shared": "workspace:*",
|
|
15
|
+
"@nestjs/common": "^12.2.0",
|
|
16
|
+
"@nestjs/core": "^12.2.0",
|
|
17
|
+
"@nestjs/platform-express": "^12.2.0",
|
|
18
|
+
"@nestjs/swagger": "^12.0.2",
|
|
19
|
+
"class-transformer": "^0.5.1",
|
|
20
|
+
"class-validator": "^0.15.1",
|
|
21
|
+
"reflect-metadata": "^0.2.2",
|
|
22
|
+
"rxjs": "^7.8.2"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@nestjs/testing": "^12.2.0",
|
|
26
|
+
"@types/bun": "^1.4.3",
|
|
27
|
+
"typescript": "^6.0.3"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import "reflect-metadata";
|
|
2
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
3
|
+
import { Test } from "@nestjs/testing";
|
|
4
|
+
import { HealthController } from "./health.controller";
|
|
5
|
+
|
|
6
|
+
describe("HealthController", () => {
|
|
7
|
+
let controller: HealthController;
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
const moduleRef = await Test.createTestingModule({
|
|
11
|
+
controllers: [HealthController],
|
|
12
|
+
}).compile();
|
|
13
|
+
controller = moduleRef.get(HealthController);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("reports ok", () => {
|
|
17
|
+
expect(controller.check().status).toBe("ok");
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { HealthResponse } from "@{{scope}}/shared";
|
|
2
|
+
import { Controller, Get } from "@nestjs/common";
|
|
3
|
+
import { ApiOkResponse, ApiTags } from "@nestjs/swagger";
|
|
4
|
+
|
|
5
|
+
@ApiTags("health")
|
|
6
|
+
@Controller("health")
|
|
7
|
+
export class HealthController {
|
|
8
|
+
@Get()
|
|
9
|
+
@ApiOkResponse({ description: "Service is up" })
|
|
10
|
+
check(): HealthResponse {
|
|
11
|
+
return {
|
|
12
|
+
status: "ok",
|
|
13
|
+
uptime: process.uptime(),
|
|
14
|
+
timestamp: new Date().toISOString(),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import "reflect-metadata";
|
|
2
|
+
import { ValidationPipe } from "@nestjs/common";
|
|
3
|
+
import { NestFactory } from "@nestjs/core";
|
|
4
|
+
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
|
5
|
+
import { AppModule } from "./app.module";
|
|
6
|
+
|
|
7
|
+
async function bootstrap() {
|
|
8
|
+
const app = await NestFactory.create(AppModule);
|
|
9
|
+
|
|
10
|
+
app.enableCors({ origin: process.env.WEB_ORIGIN ?? "http://localhost:3000" });
|
|
11
|
+
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
|
12
|
+
|
|
13
|
+
const config = new DocumentBuilder().setTitle("{{projectName}} API").setVersion("0.0.0").build();
|
|
14
|
+
SwaggerModule.setup("docs", app, () => SwaggerModule.createDocument(app, config));
|
|
15
|
+
|
|
16
|
+
const port = Number(process.env.PORT ?? 3001);
|
|
17
|
+
await app.listen(port);
|
|
18
|
+
console.log(`API listening on http://localhost:${port} (docs at /docs)`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
bootstrap();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import "./globals.css";
|
|
4
|
+
|
|
5
|
+
export const metadata: Metadata = {
|
|
6
|
+
title: "{{projectName}}",
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
10
|
+
return (
|
|
11
|
+
<html lang="en">
|
|
12
|
+
<body className="min-h-screen bg-white text-neutral-900 antialiased dark:bg-neutral-950 dark:text-neutral-100">
|
|
13
|
+
{children}
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { HealthResponse } from "@{{scope}}/shared";
|
|
2
|
+
|
|
3
|
+
export const dynamic = "force-dynamic";
|
|
4
|
+
|
|
5
|
+
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001";
|
|
6
|
+
|
|
7
|
+
async function getHealth(): Promise<HealthResponse | null> {
|
|
8
|
+
try {
|
|
9
|
+
const res = await fetch(`${API_URL}/health`, { cache: "no-store" });
|
|
10
|
+
return res.ok ? ((await res.json()) as HealthResponse) : null;
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default async function Home() {
|
|
17
|
+
const health = await getHealth();
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-24">
|
|
21
|
+
<h1 className="text-3xl font-semibold">{{projectName}}</h1>
|
|
22
|
+
<p data-testid="api-status">API: {health ? health.status : "unreachable"}</p>
|
|
23
|
+
</main>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@{{scope}}/web",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "bun --bun next dev --port 3000",
|
|
8
|
+
"build": "bun --bun next build",
|
|
9
|
+
"start": "bun --bun next start --port 3000",
|
|
10
|
+
"check": "bun check",
|
|
11
|
+
"test": "bun test ./app --pass-with-no-tests"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@{{scope}}/shared": "workspace:*",
|
|
15
|
+
"next": "^16.4.2",
|
|
16
|
+
"react": "^19.3.1",
|
|
17
|
+
"react-dom": "^19.3.1"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@tailwindcss/postcss": "^4.3.3",
|
|
21
|
+
"@types/bun": "^1.4.3",
|
|
22
|
+
"@types/node": "^26.6.5",
|
|
23
|
+
"@types/react": "^19.3.0",
|
|
24
|
+
"@types/react-dom": "^19.3.0",
|
|
25
|
+
"tailwindcss": "^4.3.3",
|
|
26
|
+
"typescript": "^6.0.3"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"lib": ["DOM", "DOM.Iterable", "ES2023"],
|
|
5
|
+
"jsx": "preserve",
|
|
6
|
+
"allowJs": true,
|
|
7
|
+
"incremental": true,
|
|
8
|
+
"types": ["bun"],
|
|
9
|
+
"plugins": [{ "name": "next" }],
|
|
10
|
+
"paths": {
|
|
11
|
+
"@/*": ["./*"]
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
15
|
+
"exclude": ["node_modules", ".next"]
|
|
16
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
test("GET /health returns ok", async ({ request }) => {
|
|
4
|
+
const res = await request.get("http://localhost:3001/health");
|
|
5
|
+
expect(res.ok()).toBe(true);
|
|
6
|
+
expect(await res.json()).toMatchObject({ status: "ok" });
|
|
7
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{projectName}}",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"packageManager": "bun@1.4.3",
|
|
6
|
+
"workspaces": [
|
|
7
|
+
"apps/*",
|
|
8
|
+
"packages/*"
|
|
9
|
+
],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"dev": "turbo dev",
|
|
12
|
+
"build": "turbo build",
|
|
13
|
+
"start": "turbo start",
|
|
14
|
+
"check": "turbo check",
|
|
15
|
+
"test": "turbo test",
|
|
16
|
+
"e2e": "playwright test",
|
|
17
|
+
"e2e:install": "playwright install chromium",
|
|
18
|
+
"commit": "cz",
|
|
19
|
+
"prepare": "husky"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@commitlint/cli": "^21.2.4",
|
|
23
|
+
"@commitlint/config-conventional": "^21.2.4",
|
|
24
|
+
"@commitlint/cz-commitlint": "^21.2.4",
|
|
25
|
+
"@playwright/test": "^1.64.1",
|
|
26
|
+
"@types/bun": "^1.4.3",
|
|
27
|
+
"commitizen": "^4.3.2",
|
|
28
|
+
"husky": "^9.1.7",
|
|
29
|
+
"inquirer": "^12.11.1",
|
|
30
|
+
"turbo": "^2.11.10",
|
|
31
|
+
"typescript": "^6.0.3"
|
|
32
|
+
},
|
|
33
|
+
"config": {
|
|
34
|
+
"commitizen": {
|
|
35
|
+
"path": "@commitlint/cz-commitlint"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@{{scope}}/shared",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"check": "bun check",
|
|
11
|
+
"test": "bun test ./src --pass-with-no-tests"
|
|
12
|
+
},
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"@types/bun": "^1.4.3",
|
|
15
|
+
"typescript": "^6.0.3"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { defineConfig, devices } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
const CI = !!process.env.CI;
|
|
4
|
+
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
testDir: "./e2e",
|
|
7
|
+
fullyParallel: true,
|
|
8
|
+
forbidOnly: CI,
|
|
9
|
+
retries: CI ? 2 : 0,
|
|
10
|
+
reporter: CI ? [["github"], ["html", { open: "never" }]] : "list",
|
|
11
|
+
use: {
|
|
12
|
+
baseURL: "http://localhost:3000",
|
|
13
|
+
trace: "on-first-retry",
|
|
14
|
+
},
|
|
15
|
+
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
|
16
|
+
webServer: [
|
|
17
|
+
{
|
|
18
|
+
command: "bun run --filter=@{{scope}}/api dev",
|
|
19
|
+
url: "http://localhost:3001/health",
|
|
20
|
+
reuseExistingServer: !CI,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
command: "bun run --filter=@{{scope}}/web dev",
|
|
24
|
+
url: "http://localhost:3000",
|
|
25
|
+
reuseExistingServer: !CI,
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"module": "Preserve",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"lib": ["ES2023"],
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noUncheckedIndexedAccess": true,
|
|
9
|
+
"noFallthroughCasesInSwitch": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"resolveJsonModule": true,
|
|
12
|
+
"isolatedModules": true,
|
|
13
|
+
"skipLibCheck": true,
|
|
14
|
+
"noEmit": true
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://turborepo.com/schema.json",
|
|
3
|
+
"ui": "stream",
|
|
4
|
+
"tasks": {
|
|
5
|
+
"build": {
|
|
6
|
+
"dependsOn": ["^build"],
|
|
7
|
+
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
|
|
8
|
+
},
|
|
9
|
+
"check": {
|
|
10
|
+
"dependsOn": ["^check"]
|
|
11
|
+
},
|
|
12
|
+
"test": {
|
|
13
|
+
"dependsOn": ["^build"]
|
|
14
|
+
},
|
|
15
|
+
"dev": {
|
|
16
|
+
"cache": false,
|
|
17
|
+
"persistent": true
|
|
18
|
+
},
|
|
19
|
+
"start": {
|
|
20
|
+
"dependsOn": ["build"],
|
|
21
|
+
"cache": false,
|
|
22
|
+
"persistent": true
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|