@emeryld/manager 0.3.3 → 0.4.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 +24 -10
- package/dist/create-package/shared.js +183 -0
- package/dist/create-package/variants/client.js +49 -25
- package/dist/create-package/variants/contract.js +40 -21
- package/dist/create-package/variants/docker.js +84 -29
- package/dist/create-package/variants/empty.js +36 -20
- package/dist/create-package/variants/fullstack.js +96 -406
- package/dist/create-package/variants/server.js +52 -26
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -32,13 +32,27 @@ Interactive release helper for pnpm monorepos. Install it as a local dev depende
|
|
|
32
32
|
|
|
33
33
|
Run `pnpm test` to ensure the helper CLI always generates a `--import data:text...` snippet that registers `ts-node/esm.mjs` with **the actual script file path**. This guards against regressions that would make Node reject the loader and crash before the interactive menu appears.
|
|
34
34
|
|
|
35
|
-
## Package creator
|
|
36
|
-
|
|
37
|
-
Use `Create package` inside the CLI to scaffold a
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
-
|
|
41
|
-
-
|
|
42
|
-
-
|
|
43
|
-
|
|
44
|
-
|
|
35
|
+
## Package creator overview
|
|
36
|
+
|
|
37
|
+
Use `Create package` inside the CLI to scaffold starter packages. The flow will prompt for a variant, target path, and package name, then generate the files and attempt `pnpm install` and `pnpm run build` when a build script exists.
|
|
38
|
+
|
|
39
|
+
### How to scaffold
|
|
40
|
+
- From the workspace root, run `pnpm manager-cli` → choose `Create package`.
|
|
41
|
+
- Pick a variant, confirm the target directory, and override the package name if desired.
|
|
42
|
+
- After scaffolding, the new package’s README covers variant-specific details and scripts.
|
|
43
|
+
|
|
44
|
+
### Variant quick view
|
|
45
|
+
|
|
46
|
+
| Variant | Default dir | Purpose | Key scripts |
|
|
47
|
+
| --- | --- | --- | --- |
|
|
48
|
+
| rrr contract | `packages/rrr-contract` | Shared RRRoutes contract registry and socket config | `dev`, `build`, `typecheck`, `lint`, `format` |
|
|
49
|
+
| rrr server | `packages/rrr-server` | Express + RRRoutes API wired to a contract import | `dev`, `build`, `start`, `lint`, `format` |
|
|
50
|
+
| rrr client | `packages/rrr-client` | RRRoutes client helpers + React Query setup | `dev`, `build`, `lint`, `format` |
|
|
51
|
+
| empty package | `packages/rrr-empty` | Minimal TypeScript starter | `dev`, `build`, `lint`, `format` |
|
|
52
|
+
| dockerized service | `packages/rrr-docker` | Express service with Dockerfile and helper CLI | `dev`, `build`, `start`, `docker:*` |
|
|
53
|
+
| full stack stack | `rrrfull-stack` | Workspace with contract + server + client + docker | `dev`, `build`, `lint`, `docker:*` (root scripts orchestrate) |
|
|
54
|
+
|
|
55
|
+
### Common scripts and helpers
|
|
56
|
+
- `dev` (watch), `build`, `typecheck`, `lint`/`lint:fix`, `format`/`format:check`, `clean`, `test` are scaffolded where relevant.
|
|
57
|
+
- Docker-focused variants expose `docker:build`, `docker:up`, `docker:dev`, `docker:logs`, `docker:stop`, `docker:clean`, `docker:reset`.
|
|
58
|
+
- Husky + lint-staged and ESLint/Prettier configs are included in every package for consistent DX.
|
|
@@ -40,5 +40,188 @@ export function baseTsConfig(options) {
|
|
|
40
40
|
jsx: options?.jsx,
|
|
41
41
|
},
|
|
42
42
|
include: options?.include ?? ['src/**/*'],
|
|
43
|
+
exclude: options?.exclude ?? ['dist', 'node_modules'],
|
|
43
44
|
}, null, 2)}\n`;
|
|
44
45
|
}
|
|
46
|
+
export function baseEslintConfig(tsconfigPath = './tsconfig.json') {
|
|
47
|
+
return `import tseslint from 'typescript-eslint'
|
|
48
|
+
import prettierPlugin from 'eslint-plugin-prettier'
|
|
49
|
+
|
|
50
|
+
export default tseslint.config(
|
|
51
|
+
{ ignores: ['dist', 'node_modules'] },
|
|
52
|
+
...tseslint.configs.recommendedTypeChecked,
|
|
53
|
+
{
|
|
54
|
+
files: ['**/*.ts', '**/*.tsx'],
|
|
55
|
+
languageOptions: {
|
|
56
|
+
parserOptions: {
|
|
57
|
+
project: ${JSON.stringify(tsconfigPath)},
|
|
58
|
+
tsconfigRootDir: import.meta.dirname,
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
plugins: {
|
|
62
|
+
prettier: prettierPlugin,
|
|
63
|
+
},
|
|
64
|
+
rules: {
|
|
65
|
+
'prettier/prettier': 'error',
|
|
66
|
+
'@typescript-eslint/consistent-type-imports': 'warn',
|
|
67
|
+
'@typescript-eslint/no-unused-vars': [
|
|
68
|
+
'warn',
|
|
69
|
+
{ argsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^ignore' },
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
)
|
|
74
|
+
`;
|
|
75
|
+
}
|
|
76
|
+
export function basePrettierConfig() {
|
|
77
|
+
return `export default {
|
|
78
|
+
singleQuote: true,
|
|
79
|
+
semi: false,
|
|
80
|
+
trailingComma: 'all',
|
|
81
|
+
printWidth: 100,
|
|
82
|
+
}
|
|
83
|
+
`;
|
|
84
|
+
}
|
|
85
|
+
export const PRETTIER_IGNORE = ['node_modules', 'dist', '.turbo', 'coverage', '*.log'].join('\n');
|
|
86
|
+
export const LINT_STAGED_CONFIG = {
|
|
87
|
+
'*.{ts,tsx}': ['eslint --fix'],
|
|
88
|
+
'*.{ts,tsx,js,jsx,json,md,css,html}': ['prettier --write'],
|
|
89
|
+
};
|
|
90
|
+
export const HUSKY_PRE_COMMIT = `#!/usr/bin/env sh
|
|
91
|
+
. "$(dirname -- "$0")/_/husky.sh"
|
|
92
|
+
|
|
93
|
+
pnpm lint-staged
|
|
94
|
+
`;
|
|
95
|
+
export function vscodeSettings() {
|
|
96
|
+
return `${JSON.stringify({
|
|
97
|
+
'editor.defaultFormatter': 'esbenp.prettier-vscode',
|
|
98
|
+
'editor.formatOnSave': true,
|
|
99
|
+
'editor.codeActionsOnSave': {
|
|
100
|
+
'source.fixAll.eslint': true,
|
|
101
|
+
},
|
|
102
|
+
'eslint.useFlatConfig': true,
|
|
103
|
+
'eslint.validate': ['typescript', 'javascript'],
|
|
104
|
+
'files.eol': '\n',
|
|
105
|
+
'prettier.requireConfig': true,
|
|
106
|
+
}, null, 2)}\n`;
|
|
107
|
+
}
|
|
108
|
+
export const BASE_LINT_DEV_DEPENDENCIES = {
|
|
109
|
+
typescript: '^5.9.3',
|
|
110
|
+
eslint: '^9.12.0',
|
|
111
|
+
'eslint-plugin-prettier': '^5.2.1',
|
|
112
|
+
prettier: '^3.3.3',
|
|
113
|
+
'typescript-eslint': '^8.10.0',
|
|
114
|
+
rimraf: '^6.0.1',
|
|
115
|
+
tsx: '^4.19.0',
|
|
116
|
+
husky: '^9.1.6',
|
|
117
|
+
'lint-staged': '^15.2.10',
|
|
118
|
+
'@emeryld/manager': 'latest',
|
|
119
|
+
};
|
|
120
|
+
const DEFAULT_GITIGNORE_ENTRIES = [
|
|
121
|
+
'node_modules',
|
|
122
|
+
'dist',
|
|
123
|
+
'.turbo',
|
|
124
|
+
'.DS_Store',
|
|
125
|
+
'.env',
|
|
126
|
+
'coverage',
|
|
127
|
+
'*.log',
|
|
128
|
+
];
|
|
129
|
+
export function gitignoreFrom(entries = DEFAULT_GITIGNORE_ENTRIES) {
|
|
130
|
+
return entries.join('\n');
|
|
131
|
+
}
|
|
132
|
+
export function baseScripts(devCommand, extras) {
|
|
133
|
+
return {
|
|
134
|
+
dev: devCommand,
|
|
135
|
+
build: 'tsc -p tsconfig.json',
|
|
136
|
+
typecheck: 'tsc -p tsconfig.json --noEmit',
|
|
137
|
+
lint: 'eslint . --max-warnings=0',
|
|
138
|
+
'lint:fix': 'eslint . --fix',
|
|
139
|
+
'lint-staged': 'lint-staged',
|
|
140
|
+
format: 'prettier . --write',
|
|
141
|
+
'format:check': 'prettier . --check',
|
|
142
|
+
clean: 'rimraf dist .turbo coverage',
|
|
143
|
+
test: "node -e \"console.log('No tests yet')\"",
|
|
144
|
+
prepare: 'husky install || true',
|
|
145
|
+
...extras,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
export function basePackageFiles(options) {
|
|
149
|
+
return {
|
|
150
|
+
'eslint.config.js': baseEslintConfig(options?.tsconfigPath),
|
|
151
|
+
'prettier.config.js': basePrettierConfig(),
|
|
152
|
+
'.prettierignore': `${PRETTIER_IGNORE}\n`,
|
|
153
|
+
'.vscode/settings.json': vscodeSettings(),
|
|
154
|
+
'.husky/pre-commit': HUSKY_PRE_COMMIT,
|
|
155
|
+
'.gitignore': gitignoreFrom(options?.gitignoreEntries),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const SCRIPT_DESCRIPTIONS = {
|
|
159
|
+
dev: 'Watch and rebuild on change',
|
|
160
|
+
build: 'Type-check and emit to dist/',
|
|
161
|
+
typecheck: 'Type-check only (no emit)',
|
|
162
|
+
lint: 'Run ESLint with flat config',
|
|
163
|
+
'lint:fix': 'Fix lint issues automatically',
|
|
164
|
+
'lint-staged': 'Run lint/format on staged files',
|
|
165
|
+
format: 'Format code with Prettier',
|
|
166
|
+
'format:check': 'Check formatting without writing',
|
|
167
|
+
clean: 'Remove build artifacts and caches',
|
|
168
|
+
test: 'Placeholder test script',
|
|
169
|
+
start: 'Run the built output',
|
|
170
|
+
'docker:build': 'Build docker image',
|
|
171
|
+
'docker:up': 'Build and start container',
|
|
172
|
+
'docker:dev': 'Build, start, and tail logs',
|
|
173
|
+
'docker:logs': 'Tail docker logs',
|
|
174
|
+
'docker:stop': 'Stop running container',
|
|
175
|
+
'docker:clean': 'Stop and remove container',
|
|
176
|
+
'docker:reset': 'Remove container and image',
|
|
177
|
+
};
|
|
178
|
+
export function buildReadme(options) {
|
|
179
|
+
const scripts = options.scripts ?? [];
|
|
180
|
+
const scriptLines = scripts.map((script) => {
|
|
181
|
+
const desc = SCRIPT_DESCRIPTIONS[script];
|
|
182
|
+
return desc ? `- \`npm run ${script}\` - ${desc}` : `- \`npm run ${script}\``;
|
|
183
|
+
});
|
|
184
|
+
const sections = [...(options.sections ?? [])];
|
|
185
|
+
if (scriptLines.length > 0) {
|
|
186
|
+
sections.push({ title: 'Scripts', lines: scriptLines });
|
|
187
|
+
}
|
|
188
|
+
const lines = [`# ${options.name}`, ''];
|
|
189
|
+
if (options.description) {
|
|
190
|
+
lines.push(options.description, '');
|
|
191
|
+
}
|
|
192
|
+
sections.forEach((section) => {
|
|
193
|
+
lines.push(`## ${section.title}`, ...section.lines, '');
|
|
194
|
+
});
|
|
195
|
+
return `${lines.join('\n').trim()}\n`;
|
|
196
|
+
}
|
|
197
|
+
function stripUndefined(obj) {
|
|
198
|
+
return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined));
|
|
199
|
+
}
|
|
200
|
+
export function basePackageJson(options) {
|
|
201
|
+
const applyDefaults = options.useDefaults ?? true;
|
|
202
|
+
const pkg = stripUndefined({
|
|
203
|
+
name: options.name,
|
|
204
|
+
version: options.version ?? '0.1.0',
|
|
205
|
+
private: options.private ?? true,
|
|
206
|
+
...(applyDefaults
|
|
207
|
+
? {
|
|
208
|
+
type: options.type ?? 'module',
|
|
209
|
+
main: options.main ?? 'dist/index.js',
|
|
210
|
+
types: options.types ?? 'dist/index.d.ts',
|
|
211
|
+
files: options.files ?? ['dist'],
|
|
212
|
+
}
|
|
213
|
+
: stripUndefined({
|
|
214
|
+
type: options.type,
|
|
215
|
+
main: options.main,
|
|
216
|
+
types: options.types,
|
|
217
|
+
files: options.files,
|
|
218
|
+
})),
|
|
219
|
+
exports: options.exports,
|
|
220
|
+
scripts: options.scripts,
|
|
221
|
+
dependencies: options.dependencies,
|
|
222
|
+
devDependencies: options.devDependencies,
|
|
223
|
+
'lint-staged': LINT_STAGED_CONFIG,
|
|
224
|
+
...options.extraFields,
|
|
225
|
+
});
|
|
226
|
+
return `${JSON.stringify(pkg, null, 2)}\n`;
|
|
227
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { baseTsConfig, writeFileIfMissing, } from '../shared.js';
|
|
1
|
+
import { BASE_LINT_DEV_DEPENDENCIES, basePackageFiles, basePackageJson, buildReadme, baseScripts, baseTsConfig, writeFileIfMissing, } from '../shared.js';
|
|
2
2
|
const CONTRACT_IMPORT_PLACEHOLDER = '@your-scope/contract';
|
|
3
|
-
function clientIndexTs(contractImport) {
|
|
3
|
+
export function clientIndexTs(contractImport) {
|
|
4
4
|
return `import { QueryClient } from '@tanstack/react-query'
|
|
5
5
|
import { createRouteClient } from '@emeryld/rrroutes-client'
|
|
6
6
|
import { registry } from '${contractImport}'
|
|
@@ -18,42 +18,65 @@ export const healthGet = routeClient.build(registry.byKey['GET /api/health'])
|
|
|
18
18
|
export const healthPost = routeClient.build(registry.byKey['POST /api/health'])
|
|
19
19
|
`;
|
|
20
20
|
}
|
|
21
|
-
function clientPackageJson(name) {
|
|
22
|
-
return
|
|
21
|
+
export function clientPackageJson(name, contractName = CONTRACT_IMPORT_PLACEHOLDER) {
|
|
22
|
+
return basePackageJson({
|
|
23
23
|
name,
|
|
24
|
-
|
|
25
|
-
private: true,
|
|
26
|
-
type: 'module',
|
|
27
|
-
main: 'dist/index.js',
|
|
28
|
-
types: 'dist/index.d.ts',
|
|
29
|
-
files: ['dist'],
|
|
30
|
-
scripts: {
|
|
31
|
-
build: 'tsc -p tsconfig.json',
|
|
32
|
-
typecheck: 'tsc -p tsconfig.json --noEmit',
|
|
33
|
-
},
|
|
24
|
+
scripts: baseScripts('tsx watch src/index.ts'),
|
|
34
25
|
dependencies: {
|
|
26
|
+
[contractName]: 'workspace:*',
|
|
35
27
|
'@emeryld/rrroutes-client': '^2.5.3',
|
|
36
|
-
'@emeryld/rrroutes-contract': '^2.5.2',
|
|
37
28
|
'@tanstack/react-query': '^5.90.12',
|
|
38
29
|
'socket.io-client': '^4.8.3',
|
|
39
30
|
},
|
|
40
31
|
devDependencies: {
|
|
32
|
+
...BASE_LINT_DEV_DEPENDENCIES,
|
|
41
33
|
'@types/node': '^24.10.2',
|
|
42
|
-
typescript: '^5.9.3',
|
|
43
34
|
},
|
|
44
|
-
}
|
|
35
|
+
});
|
|
45
36
|
}
|
|
46
37
|
function clientFiles(pkgName, contractImport) {
|
|
38
|
+
const scriptsForReadme = [
|
|
39
|
+
'dev',
|
|
40
|
+
'build',
|
|
41
|
+
'typecheck',
|
|
42
|
+
'lint',
|
|
43
|
+
'lint:fix',
|
|
44
|
+
'format',
|
|
45
|
+
'format:check',
|
|
46
|
+
'clean',
|
|
47
|
+
'test',
|
|
48
|
+
];
|
|
47
49
|
return {
|
|
48
|
-
'package.json': clientPackageJson(pkgName),
|
|
50
|
+
'package.json': clientPackageJson(pkgName, contractImport),
|
|
49
51
|
'tsconfig.json': baseTsConfig({ lib: ['ES2020', 'DOM'], types: ['node'] }),
|
|
52
|
+
...basePackageFiles(),
|
|
50
53
|
'src/index.ts': clientIndexTs(contractImport),
|
|
51
|
-
'README.md':
|
|
52
|
-
|
|
53
|
-
Starter RRRoutes client scaffold.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
'README.md': buildReadme({
|
|
55
|
+
name: pkgName,
|
|
56
|
+
description: 'Starter RRRoutes client scaffold.',
|
|
57
|
+
scripts: scriptsForReadme,
|
|
58
|
+
sections: [
|
|
59
|
+
{
|
|
60
|
+
title: 'Getting Started',
|
|
61
|
+
lines: [
|
|
62
|
+
'- Install deps: `npm install` (or `pnpm install`)',
|
|
63
|
+
'- Start dev mode: `npm run dev`',
|
|
64
|
+
'- Build output: `npm run build`',
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
title: 'Usage',
|
|
69
|
+
lines: [
|
|
70
|
+
`- Update the contract import in \`src/index.ts\` if needed (${contractImport}).`,
|
|
71
|
+
'- Use the exported `queryClient` and built route clients from `src/index.ts`.',
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
title: 'Environment',
|
|
76
|
+
lines: ['- `RRR_API_URL` (optional) sets the API base URL (default http://localhost:4000).'],
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
}),
|
|
57
80
|
};
|
|
58
81
|
}
|
|
59
82
|
export const clientVariant = {
|
|
@@ -61,7 +84,8 @@ export const clientVariant = {
|
|
|
61
84
|
label: 'rrr client',
|
|
62
85
|
defaultDir: 'packages/rrr-client',
|
|
63
86
|
async scaffold(ctx) {
|
|
64
|
-
const
|
|
87
|
+
const contractImport = ctx.contractName ?? CONTRACT_IMPORT_PLACEHOLDER;
|
|
88
|
+
const files = clientFiles(ctx.pkgName, contractImport);
|
|
65
89
|
for (const [relative, contents] of Object.entries(files)) {
|
|
66
90
|
// eslint-disable-next-line no-await-in-loop
|
|
67
91
|
await writeFileIfMissing(ctx.targetDir, relative, contents);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { baseTsConfig, writeFileIfMissing, } from '../shared.js';
|
|
2
|
-
const CONTRACT_TS = `import { defineSocketEvents, finalize, resource } from '@emeryld/rrroutes-contract'
|
|
1
|
+
import { BASE_LINT_DEV_DEPENDENCIES, basePackageFiles, basePackageJson, buildReadme, baseScripts, baseTsConfig, writeFileIfMissing, } from '../shared.js';
|
|
2
|
+
export const CONTRACT_TS = `import { defineSocketEvents, finalize, resource } from '@emeryld/rrroutes-contract'
|
|
3
3
|
import { z } from 'zod'
|
|
4
4
|
|
|
5
5
|
const routes = resource('/api')
|
|
@@ -70,45 +70,64 @@ export const socketEvents = sockets.events
|
|
|
70
70
|
export type AppRegistry = typeof registry
|
|
71
71
|
`;
|
|
72
72
|
function contractPackageJson(name) {
|
|
73
|
-
return
|
|
73
|
+
return basePackageJson({
|
|
74
74
|
name,
|
|
75
|
-
version: '0.1.0',
|
|
76
75
|
private: false,
|
|
77
|
-
type: 'module',
|
|
78
|
-
main: 'dist/index.js',
|
|
79
|
-
types: 'dist/index.d.ts',
|
|
80
76
|
exports: {
|
|
81
77
|
'.': {
|
|
82
78
|
types: './dist/index.d.ts',
|
|
83
79
|
import: './dist/index.js',
|
|
84
80
|
},
|
|
85
81
|
},
|
|
86
|
-
|
|
87
|
-
scripts: {
|
|
88
|
-
build: 'tsc -p tsconfig.json',
|
|
89
|
-
typecheck: 'tsc -p tsconfig.json --noEmit',
|
|
90
|
-
},
|
|
82
|
+
scripts: baseScripts('tsx watch src/index.ts'),
|
|
91
83
|
dependencies: {
|
|
92
84
|
'@emeryld/rrroutes-contract': '^2.5.2',
|
|
93
85
|
zod: '^4.2.1',
|
|
94
86
|
},
|
|
95
87
|
devDependencies: {
|
|
96
|
-
|
|
88
|
+
...BASE_LINT_DEV_DEPENDENCIES,
|
|
97
89
|
},
|
|
98
|
-
}
|
|
90
|
+
});
|
|
99
91
|
}
|
|
100
92
|
function contractFiles(pkgName) {
|
|
93
|
+
const scriptsForReadme = [
|
|
94
|
+
'dev',
|
|
95
|
+
'build',
|
|
96
|
+
'typecheck',
|
|
97
|
+
'lint',
|
|
98
|
+
'lint:fix',
|
|
99
|
+
'format',
|
|
100
|
+
'format:check',
|
|
101
|
+
'clean',
|
|
102
|
+
'test',
|
|
103
|
+
];
|
|
101
104
|
return {
|
|
102
105
|
'package.json': contractPackageJson(pkgName),
|
|
103
106
|
'tsconfig.json': baseTsConfig(),
|
|
107
|
+
...basePackageFiles(),
|
|
104
108
|
'src/index.ts': CONTRACT_TS,
|
|
105
|
-
'README.md':
|
|
106
|
-
|
|
107
|
-
Contract package scaffolded by manager-cli.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
109
|
+
'README.md': buildReadme({
|
|
110
|
+
name: pkgName,
|
|
111
|
+
description: 'Contract package scaffolded by manager-cli.',
|
|
112
|
+
scripts: scriptsForReadme,
|
|
113
|
+
sections: [
|
|
114
|
+
{
|
|
115
|
+
title: 'Getting Started',
|
|
116
|
+
lines: [
|
|
117
|
+
'- Install deps: `npm install` (or `pnpm install`)',
|
|
118
|
+
'- Start dev mode: `npm run dev`',
|
|
119
|
+
'- Build output: `npm run build`',
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
title: 'Usage',
|
|
124
|
+
lines: [
|
|
125
|
+
'- Edit `src/index.ts` to add routes and socket events.',
|
|
126
|
+
'- Import and share the generated registry in server/client packages.',
|
|
127
|
+
],
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
}),
|
|
112
131
|
};
|
|
113
132
|
}
|
|
114
133
|
export const contractVariant = {
|
|
@@ -1,38 +1,30 @@
|
|
|
1
|
-
import { baseTsConfig, writeFileIfMissing, } from '../shared.js';
|
|
1
|
+
import { BASE_LINT_DEV_DEPENDENCIES, basePackageFiles, basePackageJson, buildReadme, baseScripts, baseTsConfig, writeFileIfMissing, } from '../shared.js';
|
|
2
2
|
function dockerPackageJson(name) {
|
|
3
|
-
return
|
|
3
|
+
return basePackageJson({
|
|
4
4
|
name,
|
|
5
|
-
|
|
6
|
-
private: true,
|
|
7
|
-
type: 'module',
|
|
8
|
-
main: 'dist/index.js',
|
|
9
|
-
types: 'dist/index.d.ts',
|
|
10
|
-
files: ['dist'],
|
|
11
|
-
scripts: {
|
|
12
|
-
dev: 'tsx watch src/index.ts',
|
|
13
|
-
build: 'tsc -p tsconfig.json',
|
|
14
|
-
typecheck: 'tsc -p tsconfig.json --noEmit',
|
|
5
|
+
scripts: baseScripts('tsx watch src/index.ts', {
|
|
15
6
|
start: 'node dist/index.js',
|
|
16
7
|
'docker:cli': 'tsx scripts/docker.ts',
|
|
17
8
|
'docker:build': 'npm run docker:cli -- build',
|
|
18
9
|
'docker:up': 'npm run docker:cli -- up',
|
|
10
|
+
'docker:dev': 'npm run docker:cli -- dev',
|
|
19
11
|
'docker:logs': 'npm run docker:cli -- logs',
|
|
20
12
|
'docker:stop': 'npm run docker:cli -- stop',
|
|
21
13
|
'docker:clean': 'npm run docker:cli -- clean',
|
|
22
|
-
|
|
14
|
+
'docker:reset': 'npm run docker:cli -- reset',
|
|
15
|
+
}),
|
|
23
16
|
dependencies: {
|
|
24
17
|
cors: '^2.8.5',
|
|
25
18
|
express: '^5.1.0',
|
|
26
19
|
},
|
|
27
20
|
devDependencies: {
|
|
21
|
+
...BASE_LINT_DEV_DEPENDENCIES,
|
|
28
22
|
'@types/cors': '^2.8.5',
|
|
29
23
|
'@types/express': '^5.0.6',
|
|
30
24
|
'@types/node': '^24.10.2',
|
|
31
25
|
'docker-cli-js': '^3.0.9',
|
|
32
|
-
tsx: '^4.19.0',
|
|
33
|
-
typescript: '^5.9.3',
|
|
34
26
|
},
|
|
35
|
-
}
|
|
27
|
+
});
|
|
36
28
|
}
|
|
37
29
|
function dockerIndexTs() {
|
|
38
30
|
return `import express from 'express'
|
|
@@ -85,20 +77,65 @@ CMD ["node", "dist/index.js"]
|
|
|
85
77
|
`;
|
|
86
78
|
}
|
|
87
79
|
function dockerFiles(pkgName) {
|
|
80
|
+
const scriptsForReadme = [
|
|
81
|
+
'dev',
|
|
82
|
+
'build',
|
|
83
|
+
'typecheck',
|
|
84
|
+
'lint',
|
|
85
|
+
'lint:fix',
|
|
86
|
+
'format',
|
|
87
|
+
'format:check',
|
|
88
|
+
'clean',
|
|
89
|
+
'test',
|
|
90
|
+
'start',
|
|
91
|
+
'docker:build',
|
|
92
|
+
'docker:up',
|
|
93
|
+
'docker:dev',
|
|
94
|
+
'docker:logs',
|
|
95
|
+
'docker:stop',
|
|
96
|
+
'docker:clean',
|
|
97
|
+
'docker:reset',
|
|
98
|
+
];
|
|
88
99
|
return {
|
|
89
100
|
'package.json': dockerPackageJson(pkgName),
|
|
90
101
|
'tsconfig.json': baseTsConfig({ types: ['node'] }),
|
|
91
102
|
'src/index.ts': dockerIndexTs(),
|
|
92
103
|
'scripts/docker.ts': dockerCliScript(pkgName),
|
|
93
104
|
'.dockerignore': DOCKER_DOCKERIGNORE,
|
|
105
|
+
...basePackageFiles(),
|
|
94
106
|
Dockerfile: dockerDockerfile(),
|
|
95
|
-
'README.md':
|
|
96
|
-
|
|
97
|
-
Dockerized service scaffolded by manager-cli.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
107
|
+
'README.md': buildReadme({
|
|
108
|
+
name: pkgName,
|
|
109
|
+
description: 'Dockerized service scaffolded by manager-cli.',
|
|
110
|
+
scripts: scriptsForReadme,
|
|
111
|
+
sections: [
|
|
112
|
+
{
|
|
113
|
+
title: 'Getting Started',
|
|
114
|
+
lines: [
|
|
115
|
+
'- Install deps: `npm install` (or `pnpm install`)',
|
|
116
|
+
'- Local dev: `npm run dev`',
|
|
117
|
+
'- Build output: `npm run build`',
|
|
118
|
+
'- Start built app: `npm start`',
|
|
119
|
+
],
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
title: 'Docker Helpers',
|
|
123
|
+
lines: [
|
|
124
|
+
'- Build image: `npm run docker:build`',
|
|
125
|
+
'- Build + run detached: `npm run docker:up`',
|
|
126
|
+
'- Build + run + tail logs: `npm run docker:dev`',
|
|
127
|
+
'- Tail logs: `npm run docker:logs`',
|
|
128
|
+
'- Stop container: `npm run docker:stop`',
|
|
129
|
+
'- Clean container: `npm run docker:clean`',
|
|
130
|
+
'- Reset container/image: `npm run docker:reset`',
|
|
131
|
+
],
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
title: 'Environment',
|
|
135
|
+
lines: ['- `PORT` sets the exposed port (default 3000).'],
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
}),
|
|
102
139
|
};
|
|
103
140
|
}
|
|
104
141
|
function dockerCliScript(pkgName) {
|
|
@@ -126,32 +163,50 @@ async function main() {
|
|
|
126
163
|
await docker.command(\`build -t \${image} .\`)
|
|
127
164
|
return docker.command(\`run -d --rm -p \${port}:\${port} --name \${container} \${image}\`)
|
|
128
165
|
}
|
|
166
|
+
if (command === 'dev') {
|
|
167
|
+
await docker.command(\`build -t \${image} .\`)
|
|
168
|
+
await docker.command(\`run -d --rm -p \${port}:\${port} --name \${container} \${image}\`)
|
|
169
|
+
return docker.command(\`logs -f \${container}\`)
|
|
170
|
+
}
|
|
129
171
|
if (command === 'run') {
|
|
130
172
|
return docker.command(\`run -d --rm -p \${port}:\${port} --name \${container} \${image}\`)
|
|
131
173
|
}
|
|
132
174
|
if (command === 'logs') return docker.command(\`logs -f \${container}\`)
|
|
133
175
|
if (command === 'stop') return docker.command(\`stop \${container}\`)
|
|
134
176
|
if (command === 'clean') {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
177
|
+
await safe(() => docker.command(\`stop \${container}\`))
|
|
178
|
+
await safe(() => docker.command(\`rm -f \${container}\`))
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
if (command === 'reset') {
|
|
182
|
+
await safe(() => docker.command(\`stop \${container}\`))
|
|
183
|
+
await safe(() => docker.command(\`rm -f \${container}\`))
|
|
184
|
+
await safe(() => docker.command(\`rmi -f \${image}\`))
|
|
140
185
|
return
|
|
141
186
|
}
|
|
142
187
|
return printHelp()
|
|
143
188
|
}
|
|
144
189
|
|
|
190
|
+
async function safe(run: () => Promise<unknown>) {
|
|
191
|
+
try {
|
|
192
|
+
await run()
|
|
193
|
+
} catch (error) {
|
|
194
|
+
console.warn(String(error))
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
145
198
|
function printHelp() {
|
|
146
199
|
console.log(
|
|
147
200
|
[
|
|
148
201
|
'Docker helper commands:',
|
|
149
202
|
' build -> docker build -t ${pkgName}:latest .',
|
|
150
203
|
' up -> build then run in detached mode',
|
|
204
|
+
' dev -> build, run, and tail logs',
|
|
151
205
|
' run -> run existing image detached',
|
|
152
206
|
' logs -> docker logs -f <container>',
|
|
153
207
|
' stop -> docker stop <container>',
|
|
154
|
-
' clean -> docker rm
|
|
208
|
+
' clean -> docker stop/rm <container>',
|
|
209
|
+
' reset -> clean container and remove image',
|
|
155
210
|
].join('\\n'),
|
|
156
211
|
)
|
|
157
212
|
}
|
|
@@ -1,33 +1,49 @@
|
|
|
1
|
-
import { baseTsConfig, writeFileIfMissing, } from '../shared.js';
|
|
1
|
+
import { BASE_LINT_DEV_DEPENDENCIES, basePackageFiles, basePackageJson, buildReadme, baseScripts, baseTsConfig, writeFileIfMissing, } from '../shared.js';
|
|
2
2
|
function emptyPackageJson(name) {
|
|
3
|
-
return
|
|
3
|
+
return basePackageJson({
|
|
4
4
|
name,
|
|
5
|
-
|
|
6
|
-
private: true,
|
|
7
|
-
type: 'module',
|
|
8
|
-
main: 'dist/index.js',
|
|
9
|
-
types: 'dist/index.d.ts',
|
|
10
|
-
files: ['dist'],
|
|
11
|
-
scripts: {
|
|
12
|
-
build: 'tsc -p tsconfig.json',
|
|
13
|
-
typecheck: 'tsc -p tsconfig.json --noEmit',
|
|
14
|
-
},
|
|
5
|
+
scripts: baseScripts('tsx watch src/index.ts'),
|
|
15
6
|
devDependencies: {
|
|
16
|
-
|
|
7
|
+
...BASE_LINT_DEV_DEPENDENCIES,
|
|
17
8
|
},
|
|
18
|
-
}
|
|
9
|
+
});
|
|
19
10
|
}
|
|
20
11
|
function emptyFiles(pkgName) {
|
|
12
|
+
const scriptsForReadme = [
|
|
13
|
+
'dev',
|
|
14
|
+
'build',
|
|
15
|
+
'typecheck',
|
|
16
|
+
'lint',
|
|
17
|
+
'lint:fix',
|
|
18
|
+
'format',
|
|
19
|
+
'format:check',
|
|
20
|
+
'clean',
|
|
21
|
+
'test',
|
|
22
|
+
];
|
|
21
23
|
return {
|
|
22
24
|
'package.json': emptyPackageJson(pkgName),
|
|
23
25
|
'tsconfig.json': baseTsConfig({ types: ['node'] }),
|
|
26
|
+
...basePackageFiles(),
|
|
24
27
|
'src/index.ts': "export const hello = 'world'\n",
|
|
25
|
-
'README.md':
|
|
26
|
-
|
|
27
|
-
Empty package scaffolded by manager-cli.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
'README.md': buildReadme({
|
|
29
|
+
name: pkgName,
|
|
30
|
+
description: 'Empty package scaffolded by manager-cli.',
|
|
31
|
+
scripts: scriptsForReadme,
|
|
32
|
+
sections: [
|
|
33
|
+
{
|
|
34
|
+
title: 'Getting Started',
|
|
35
|
+
lines: [
|
|
36
|
+
'- Install deps: `npm install` (or `pnpm install`)',
|
|
37
|
+
'- Start dev mode: `npm run dev`',
|
|
38
|
+
'- Build output: `npm run build`',
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
title: 'Notes',
|
|
43
|
+
lines: ['- Edit `src/index.ts` to start coding.'],
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
}),
|
|
31
47
|
};
|
|
32
48
|
}
|
|
33
49
|
export const emptyVariant = {
|
|
@@ -1,219 +1,65 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { BASE_LINT_DEV_DEPENDENCIES, basePackageFiles, basePackageJson, baseTsConfig, writeFileIfMissing, } from '../shared.js';
|
|
3
|
+
import { clientVariant } from './client.js';
|
|
4
|
+
import { serverVariant } from './server.js';
|
|
5
|
+
import { dockerVariant } from './docker.js';
|
|
6
|
+
import { contractVariant } from './contract.js';
|
|
7
|
+
function deriveNames(baseName) {
|
|
8
|
+
const normalized = baseName.trim();
|
|
9
|
+
return {
|
|
10
|
+
contract: `@${normalized}/contract`,
|
|
11
|
+
server: `@${normalized}/server`,
|
|
12
|
+
client: `@${normalized}/client`,
|
|
13
|
+
docker: `${normalized}-docker`,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function deriveDirs(rootDir, baseName) {
|
|
17
|
+
const packagesRoot = path.join(rootDir, 'packages');
|
|
18
|
+
return {
|
|
19
|
+
root: rootDir,
|
|
20
|
+
packagesRoot,
|
|
21
|
+
contract: path.join(packagesRoot, `${baseName}-contract`),
|
|
22
|
+
server: path.join(packagesRoot, `${baseName}-server`),
|
|
23
|
+
client: path.join(packagesRoot, `${baseName}-client`),
|
|
24
|
+
docker: path.join(packagesRoot, `${baseName}-docker`),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function rootPackageJson(baseName) {
|
|
28
|
+
const dockerPackageDir = `packages/${baseName}-docker`;
|
|
29
|
+
return basePackageJson({
|
|
30
|
+
name: `${baseName}-stack`,
|
|
6
31
|
private: true,
|
|
7
|
-
|
|
8
|
-
main: 'dist/server/index.js',
|
|
9
|
-
files: ['dist'],
|
|
32
|
+
useDefaults: false,
|
|
10
33
|
scripts: {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
'
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
'
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
'docker:
|
|
24
|
-
'docker:
|
|
25
|
-
'
|
|
26
|
-
'
|
|
27
|
-
|
|
28
|
-
dependencies: {
|
|
29
|
-
'@emeryld/rrroutes-client': '^2.5.3',
|
|
30
|
-
'@emeryld/rrroutes-contract': '^2.5.2',
|
|
31
|
-
'@emeryld/rrroutes-server': '^2.4.1',
|
|
32
|
-
'@tanstack/react-query': '^5.90.12',
|
|
33
|
-
cors: '^2.8.5',
|
|
34
|
-
express: '^5.1.0',
|
|
35
|
-
pg: '^8.13.1',
|
|
36
|
-
react: '^18.3.1',
|
|
37
|
-
'react-dom': '^18.3.1',
|
|
38
|
-
zod: '^4.2.1',
|
|
34
|
+
setup: 'pnpm install && pnpm exec husky install',
|
|
35
|
+
dev: 'pnpm -r dev --parallel --if-present',
|
|
36
|
+
build: 'pnpm -r build',
|
|
37
|
+
typecheck: 'pnpm -r typecheck',
|
|
38
|
+
lint: 'pnpm -r lint --if-present',
|
|
39
|
+
'lint:fix': 'pnpm -r lint:fix --if-present',
|
|
40
|
+
'lint-staged': 'lint-staged',
|
|
41
|
+
format: 'pnpm -r format --if-present',
|
|
42
|
+
'format:check': 'pnpm -r format:check --if-present',
|
|
43
|
+
test: 'pnpm -r test --if-present',
|
|
44
|
+
clean: 'pnpm -r clean --if-present && rimraf node_modules .turbo coverage',
|
|
45
|
+
prepare: 'husky install || true',
|
|
46
|
+
'docker:up': `pnpm -C ${dockerPackageDir} run docker:up`,
|
|
47
|
+
'docker:dev': `pnpm -C ${dockerPackageDir} run docker:dev`,
|
|
48
|
+
'docker:logs': `pnpm -C ${dockerPackageDir} run docker:logs`,
|
|
49
|
+
'docker:stop': `pnpm -C ${dockerPackageDir} run docker:stop`,
|
|
50
|
+
'docker:reset': `pnpm -C ${dockerPackageDir} run docker:reset`,
|
|
39
51
|
},
|
|
40
|
-
devDependencies: {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
'@types/react': '^18.3.27',
|
|
44
|
-
'@types/react-dom': '^18.3.7',
|
|
45
|
-
'@types/pg': '^8.11.10',
|
|
46
|
-
'@vitejs/plugin-react': '^4.3.4',
|
|
47
|
-
concurrently: '^8.2.0',
|
|
48
|
-
'docker-cli-js': '^3.0.9',
|
|
49
|
-
tsx: '^4.19.0',
|
|
50
|
-
typescript: '^5.9.3',
|
|
51
|
-
vite: '^6.4.1',
|
|
52
|
+
devDependencies: { ...BASE_LINT_DEV_DEPENDENCIES },
|
|
53
|
+
extraFields: {
|
|
54
|
+
workspaces: ['packages/*'],
|
|
52
55
|
},
|
|
53
|
-
}
|
|
56
|
+
});
|
|
54
57
|
}
|
|
55
|
-
function
|
|
56
|
-
return
|
|
57
|
-
import path from 'node:path'
|
|
58
|
-
import express from 'express'
|
|
59
|
-
import cors from 'cors'
|
|
60
|
-
import { Pool } from 'pg'
|
|
61
|
-
import { createRRRoute } from '@emeryld/rrroutes-server'
|
|
62
|
-
import { registry } from '../contract/index.js'
|
|
63
|
-
|
|
64
|
-
const app = express()
|
|
65
|
-
app.use(cors({ origin: '*' }))
|
|
66
|
-
app.use(express.json())
|
|
67
|
-
|
|
68
|
-
const pool = new Pool({
|
|
69
|
-
connectionString:
|
|
70
|
-
process.env.DATABASE_URL ??
|
|
71
|
-
'postgresql://postgres:postgres@localhost:5432/rrroutes',
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
const routes = createRRRoute(app, {
|
|
75
|
-
buildCtx: async () => ({
|
|
76
|
-
requestId: Math.random().toString(36).slice(2),
|
|
77
|
-
}),
|
|
78
|
-
debug:
|
|
79
|
-
process.env.NODE_ENV === 'development'
|
|
80
|
-
? { request: true, handler: true }
|
|
81
|
-
: undefined,
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
routes.registerControllers(registry, {
|
|
85
|
-
'GET /api/health': {
|
|
86
|
-
handler: async () => {
|
|
87
|
-
let db = 'skipped'
|
|
88
|
-
try {
|
|
89
|
-
await pool.query('select 1')
|
|
90
|
-
db = 'ok'
|
|
91
|
-
} catch (error) {
|
|
92
|
-
db = 'error: ' + String(error)
|
|
93
|
-
}
|
|
94
|
-
return {
|
|
95
|
-
out: {
|
|
96
|
-
status: 'ok',
|
|
97
|
-
at: new Date().toISOString(),
|
|
98
|
-
db,
|
|
99
|
-
},
|
|
100
|
-
}
|
|
101
|
-
},
|
|
102
|
-
},
|
|
103
|
-
})
|
|
104
|
-
|
|
105
|
-
const clientDir = path.resolve(__dirname, '../client')
|
|
106
|
-
if (existsSync(clientDir)) {
|
|
107
|
-
app.use(express.static(clientDir))
|
|
108
|
-
app.get('*', (_req, res) => {
|
|
109
|
-
res.sendFile(path.join(clientDir, 'index.html'))
|
|
110
|
-
})
|
|
111
|
-
} else {
|
|
112
|
-
console.warn('Client bundle missing; run "npm run build:client" to enable static assets.')
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const PORT = Number.parseInt(process.env.PORT ?? '8080', 10)
|
|
116
|
-
|
|
117
|
-
app.listen(PORT, () => {
|
|
118
|
-
console.log(\`Full stack service running on http://localhost:\${PORT}\`)
|
|
119
|
-
})
|
|
120
|
-
`;
|
|
121
|
-
}
|
|
122
|
-
const FULLSTACK_APP_TSX = `import React from 'react'
|
|
123
|
-
import { QueryClientProvider } from '@tanstack/react-query'
|
|
124
|
-
import { queryClient, healthGet } from './client/api'
|
|
125
|
-
|
|
126
|
-
function HealthCard() {
|
|
127
|
-
const health = healthGet.useEndpoint()
|
|
128
|
-
return (
|
|
129
|
-
<section
|
|
130
|
-
style={{
|
|
131
|
-
background: '#fff',
|
|
132
|
-
padding: 16,
|
|
133
|
-
borderRadius: 12,
|
|
134
|
-
boxShadow: '0 8px 24px rgba(12, 18, 32, 0.05)',
|
|
135
|
-
border: '1px solid #e5e7eb',
|
|
136
|
-
}}
|
|
137
|
-
>
|
|
138
|
-
<h2>Health</h2>
|
|
139
|
-
<button onClick={() => health.refetch()}>Ping API</button>
|
|
140
|
-
<pre style={{ whiteSpace: 'pre-wrap' }}>
|
|
141
|
-
{JSON.stringify(health.data ?? { note: 'Waiting for request...' }, null, 2)}
|
|
142
|
-
</pre>
|
|
143
|
-
</section>
|
|
144
|
-
)
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
export function App() {
|
|
148
|
-
return (
|
|
149
|
-
<QueryClientProvider client={queryClient}>
|
|
150
|
-
<main style={{ fontFamily: 'Inter, system-ui, sans-serif', padding: 24 }}>
|
|
151
|
-
<h1>Full stack service</h1>
|
|
152
|
-
<p>
|
|
153
|
-
Contract-driven API + Vite client. Health endpoint uses RRRoutes and checks the
|
|
154
|
-
Postgres connection.
|
|
155
|
-
</p>
|
|
156
|
-
<p>Edit <code>src/contract/index.ts</code> or <code>src/server/index.ts</code> to extend the API.</p>
|
|
157
|
-
<HealthCard />
|
|
158
|
-
</main>
|
|
159
|
-
</QueryClientProvider>
|
|
160
|
-
)
|
|
161
|
-
}
|
|
162
|
-
`;
|
|
163
|
-
const FULLSTACK_MAIN_TSX = `import React from 'react'
|
|
164
|
-
import ReactDOM from 'react-dom/client'
|
|
165
|
-
import { App } from './App'
|
|
166
|
-
import './styles.css'
|
|
167
|
-
|
|
168
|
-
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
169
|
-
<React.StrictMode>
|
|
170
|
-
<App />
|
|
171
|
-
</React.StrictMode>,
|
|
172
|
-
)
|
|
173
|
-
`;
|
|
174
|
-
const FULLSTACK_STYLES = `:root {
|
|
175
|
-
background: radial-gradient(circle at 20% 20%, #eef2ff, #f7f8fb 45%);
|
|
176
|
-
color: #0b1021;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
body {
|
|
180
|
-
margin: 0;
|
|
181
|
-
}
|
|
182
|
-
`;
|
|
183
|
-
const FULLSTACK_INDEX_HTML = `<!doctype html>
|
|
184
|
-
<html lang="en">
|
|
185
|
-
<head>
|
|
186
|
-
<meta charset="UTF-8" />
|
|
187
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
188
|
-
<title>Full stack service</title>
|
|
189
|
-
</head>
|
|
190
|
-
<body>
|
|
191
|
-
<div id="root"></div>
|
|
192
|
-
<script type="module" src="/src/client/main.tsx"></script>
|
|
193
|
-
</body>
|
|
194
|
-
</html>
|
|
195
|
-
`;
|
|
196
|
-
function fullstackViteConfig() {
|
|
197
|
-
return `import { defineConfig } from 'vite'
|
|
198
|
-
import react from '@vitejs/plugin-react'
|
|
199
|
-
|
|
200
|
-
export default defineConfig({
|
|
201
|
-
plugins: [react()],
|
|
202
|
-
build: {
|
|
203
|
-
outDir: 'dist/client',
|
|
204
|
-
},
|
|
205
|
-
})
|
|
206
|
-
`;
|
|
58
|
+
function rootPnpmWorkspace() {
|
|
59
|
+
return "packages:\n - 'packages/*'\n";
|
|
207
60
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
.git
|
|
211
|
-
.env
|
|
212
|
-
npm-debug.log*
|
|
213
|
-
pnpm-lock.yaml
|
|
214
|
-
yarn.lock
|
|
215
|
-
`;
|
|
216
|
-
const FULLSTACK_DOCKER_COMPOSE = `services:
|
|
61
|
+
function stackComposeYaml() {
|
|
62
|
+
return `services:
|
|
217
63
|
db:
|
|
218
64
|
image: postgres:15
|
|
219
65
|
restart: unless-stopped
|
|
@@ -229,211 +75,55 @@ const FULLSTACK_DOCKER_COMPOSE = `services:
|
|
|
229
75
|
volumes:
|
|
230
76
|
db-data:
|
|
231
77
|
`;
|
|
232
|
-
function fullstackDockerfile() {
|
|
233
|
-
return `FROM node:20-slim AS builder
|
|
234
|
-
WORKDIR /app
|
|
235
|
-
|
|
236
|
-
COPY package*.json ./
|
|
237
|
-
COPY pnpm-lock.yaml* ./
|
|
238
|
-
RUN npm install
|
|
239
|
-
|
|
240
|
-
COPY . .
|
|
241
|
-
RUN npm run build
|
|
242
|
-
|
|
243
|
-
FROM node:20-slim AS runner
|
|
244
|
-
WORKDIR /app
|
|
245
|
-
ENV NODE_ENV=production
|
|
246
|
-
|
|
247
|
-
COPY --from=builder /app/package*.json ./
|
|
248
|
-
COPY --from=builder /app/node_modules ./node_modules
|
|
249
|
-
COPY --from=builder /app/dist ./dist
|
|
250
|
-
RUN npm prune --omit=dev || true
|
|
251
|
-
|
|
252
|
-
EXPOSE 8080
|
|
253
|
-
CMD ["node", "dist/server/index.js"]
|
|
254
|
-
`;
|
|
255
78
|
}
|
|
256
|
-
function
|
|
257
|
-
|
|
258
|
-
'package.json':
|
|
79
|
+
async function scaffoldRootFiles(baseDir, baseName) {
|
|
80
|
+
const files = {
|
|
81
|
+
'package.json': rootPackageJson(baseName),
|
|
82
|
+
'pnpm-workspace.yaml': rootPnpmWorkspace(),
|
|
83
|
+
'docker-compose.yml': stackComposeYaml(),
|
|
259
84
|
'tsconfig.json': baseTsConfig({
|
|
260
|
-
|
|
261
|
-
types: ['node'],
|
|
262
|
-
jsx: 'react-jsx',
|
|
263
|
-
include: ['src/**/*', 'vite.config.ts'],
|
|
264
|
-
}),
|
|
265
|
-
'tsconfig.server.json': baseTsConfig({
|
|
266
|
-
types: ['node'],
|
|
267
|
-
rootDir: 'src',
|
|
85
|
+
rootDir: '.',
|
|
268
86
|
outDir: 'dist',
|
|
269
|
-
include: ['
|
|
87
|
+
include: ['packages/**/*', 'scripts/**/*', 'src/**/*'],
|
|
270
88
|
}),
|
|
271
|
-
|
|
272
|
-
'src/contract/index.ts': fullstackContractTs(),
|
|
273
|
-
'src/server/index.ts': fullstackServerIndexTs(),
|
|
274
|
-
'src/server/env.d.ts': "declare namespace NodeJS { interface ProcessEnv { DATABASE_URL?: string; PORT?: string } }\n",
|
|
275
|
-
'src/client/api.ts': fullstackClientApi(),
|
|
276
|
-
'src/client/App.tsx': FULLSTACK_APP_TSX,
|
|
277
|
-
'src/client/main.tsx': FULLSTACK_MAIN_TSX,
|
|
278
|
-
'src/client/styles.css': FULLSTACK_STYLES,
|
|
279
|
-
'index.html': FULLSTACK_INDEX_HTML,
|
|
280
|
-
'scripts/docker.ts': fullstackDockerCliScript(pkgName),
|
|
281
|
-
'.dockerignore': FULLSTACK_DOCKERIGNORE,
|
|
282
|
-
'docker-compose.yml': FULLSTACK_DOCKER_COMPOSE,
|
|
283
|
-
Dockerfile: fullstackDockerfile(),
|
|
284
|
-
'.env.example': 'PORT=8080\nDATABASE_URL=postgresql://postgres:postgres@localhost:5432/rrroutes\nVITE_API_URL=http://localhost:8080\n',
|
|
285
|
-
'README.md': `# ${pkgName}
|
|
286
|
-
|
|
287
|
-
Full stack (API + Vite web) scaffolded by manager-cli.
|
|
288
|
-
- dev: \`npm run dev\` (runs API + Vite client)
|
|
289
|
-
- build: \`npm run build\` (server to dist/server, client to dist/client)
|
|
290
|
-
- start: \`npm start\` (serves API and static client from dist)
|
|
291
|
-
- docker helper: \`npm run docker:up\` (build + run), \`npm run docker:logs\`, \`npm run docker:stop\`
|
|
292
|
-
- db helper: \`npm run db:up\` to start Postgres via docker-compose (uses DATABASE_URL)
|
|
293
|
-
`,
|
|
89
|
+
...basePackageFiles(),
|
|
294
90
|
};
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
import { z } from 'zod'
|
|
299
|
-
|
|
300
|
-
const routes = resource('/api')
|
|
301
|
-
.sub(
|
|
302
|
-
resource('health')
|
|
303
|
-
.get({
|
|
304
|
-
outputSchema: z.object({
|
|
305
|
-
status: z.literal('ok'),
|
|
306
|
-
at: z.string(),
|
|
307
|
-
db: z.string(),
|
|
308
|
-
}),
|
|
309
|
-
description: 'Health check with DB status.',
|
|
310
|
-
})
|
|
311
|
-
.done(),
|
|
312
|
-
)
|
|
313
|
-
.done()
|
|
314
|
-
|
|
315
|
-
export const registry = finalize(routes)
|
|
316
|
-
|
|
317
|
-
const sockets = defineSocketEvents(
|
|
318
|
-
{
|
|
319
|
-
pingPayload: z.object({
|
|
320
|
-
note: z.string().default('ping'),
|
|
321
|
-
sentAt: z.string(),
|
|
322
|
-
}),
|
|
323
|
-
pongPayload: z.object({
|
|
324
|
-
ok: z.boolean(),
|
|
325
|
-
receivedAt: z.string(),
|
|
326
|
-
echo: z.string().optional(),
|
|
327
|
-
}),
|
|
328
|
-
},
|
|
329
|
-
{
|
|
330
|
-
'health:ping': {
|
|
331
|
-
message: z.object({
|
|
332
|
-
note: z.string().default('ping'),
|
|
333
|
-
}),
|
|
334
|
-
},
|
|
335
|
-
'health:pong': {
|
|
336
|
-
message: z.object({
|
|
337
|
-
ok: z.literal(true),
|
|
338
|
-
at: z.string(),
|
|
339
|
-
echo: z.string().optional(),
|
|
340
|
-
}),
|
|
341
|
-
},
|
|
342
|
-
},
|
|
343
|
-
)
|
|
344
|
-
|
|
345
|
-
export const socketConfig = sockets.config
|
|
346
|
-
export const socketEvents = sockets.events
|
|
347
|
-
export type AppRegistry = typeof registry
|
|
348
|
-
`;
|
|
349
|
-
}
|
|
350
|
-
function fullstackClientApi() {
|
|
351
|
-
return `import { QueryClient } from '@tanstack/react-query'
|
|
352
|
-
import { createRouteClient } from '@emeryld/rrroutes-client'
|
|
353
|
-
import { registry } from '../contract'
|
|
354
|
-
|
|
355
|
-
const baseUrl = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
|
356
|
-
export const queryClient = new QueryClient()
|
|
357
|
-
|
|
358
|
-
export const routeClient = createRouteClient({
|
|
359
|
-
baseUrl,
|
|
360
|
-
queryClient,
|
|
361
|
-
environment: import.meta.env.MODE === 'production' ? 'production' : 'development',
|
|
362
|
-
})
|
|
363
|
-
|
|
364
|
-
export const healthGet = routeClient.build(registry.byKey['GET /api/health'])
|
|
365
|
-
`;
|
|
366
|
-
}
|
|
367
|
-
function fullstackDockerCliScript(pkgName) {
|
|
368
|
-
return `#!/usr/bin/env tsx
|
|
369
|
-
import { readFile } from 'node:fs/promises'
|
|
370
|
-
import path from 'node:path'
|
|
371
|
-
import { fileURLToPath } from 'node:url'
|
|
372
|
-
import { Docker } from 'docker-cli-js'
|
|
373
|
-
|
|
374
|
-
const __filename = fileURLToPath(import.meta.url)
|
|
375
|
-
const __dirname = path.dirname(__filename)
|
|
376
|
-
const pkgRaw = await readFile(path.join(__dirname, '..', 'package.json'), 'utf8')
|
|
377
|
-
const pkg = JSON.parse(pkgRaw) as { name?: string }
|
|
378
|
-
const image = \`\${pkg.name ?? '${pkgName}'}:latest\`
|
|
379
|
-
const container =
|
|
380
|
-
(pkg.name ?? '${pkgName}').replace(/[^a-z0-9]/gi, '-').replace(/^-+|-+$/g, '') || 'rrr-fullstack'
|
|
381
|
-
const port = process.env.PORT ?? '8080'
|
|
382
|
-
const docker = new Docker({ spawnOptions: { stdio: 'inherit' } })
|
|
383
|
-
|
|
384
|
-
async function main() {
|
|
385
|
-
const [command = 'help'] = process.argv.slice(2)
|
|
386
|
-
if (command === 'help') return printHelp()
|
|
387
|
-
if (command === 'build') return docker.command(\`build -t \${image} .\`)
|
|
388
|
-
if (command === 'up') {
|
|
389
|
-
await docker.command(\`build -t \${image} .\`)
|
|
390
|
-
return docker.command(\`run -d --rm -p \${port}:\${port} --name \${container} \${image}\`)
|
|
391
|
-
}
|
|
392
|
-
if (command === 'run') {
|
|
393
|
-
return docker.command(\`run -d --rm -p \${port}:\${port} --name \${container} \${image}\`)
|
|
394
|
-
}
|
|
395
|
-
if (command === 'logs') return docker.command(\`logs -f \${container}\`)
|
|
396
|
-
if (command === 'stop') return docker.command(\`stop \${container}\`)
|
|
397
|
-
if (command === 'clean') {
|
|
398
|
-
try {
|
|
399
|
-
await docker.command(\`rm -f \${container}\`)
|
|
400
|
-
} catch (error) {
|
|
401
|
-
console.warn(String(error))
|
|
91
|
+
for (const [relative, contents] of Object.entries(files)) {
|
|
92
|
+
// eslint-disable-next-line no-await-in-loop
|
|
93
|
+
await writeFileIfMissing(baseDir, relative, contents);
|
|
402
94
|
}
|
|
403
|
-
return
|
|
404
|
-
}
|
|
405
|
-
return printHelp()
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
function printHelp() {
|
|
409
|
-
console.log(
|
|
410
|
-
[
|
|
411
|
-
'Docker helper commands:',
|
|
412
|
-
' build -> docker build -t ${pkgName}:latest .',
|
|
413
|
-
' up -> build then run in detached mode',
|
|
414
|
-
' run -> run existing image detached',
|
|
415
|
-
' logs -> docker logs -f <container>',
|
|
416
|
-
' stop -> docker stop <container>',
|
|
417
|
-
' clean -> docker rm -f <container>',
|
|
418
|
-
].join('\\n'),
|
|
419
|
-
)
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
main().catch((err) => {
|
|
423
|
-
console.error(err)
|
|
424
|
-
process.exit(1)
|
|
425
|
-
})
|
|
426
|
-
`;
|
|
427
95
|
}
|
|
428
96
|
export const fullstackVariant = {
|
|
429
97
|
id: 'rrr-fullstack',
|
|
430
|
-
label: '
|
|
431
|
-
defaultDir: '
|
|
98
|
+
label: 'rrr fullstack (contract + server + client + docker)',
|
|
99
|
+
defaultDir: 'rrrfull-stack',
|
|
432
100
|
async scaffold(ctx) {
|
|
433
|
-
const
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
101
|
+
const baseName = ctx.pkgName;
|
|
102
|
+
const names = deriveNames(baseName);
|
|
103
|
+
const dirs = deriveDirs(ctx.targetDir, baseName);
|
|
104
|
+
// Root workspace files
|
|
105
|
+
await scaffoldRootFiles(dirs.root, baseName);
|
|
106
|
+
// Contract package (reuse contract variant)
|
|
107
|
+
await contractVariant.scaffold({
|
|
108
|
+
targetDir: dirs.contract,
|
|
109
|
+
pkgName: names.contract,
|
|
110
|
+
});
|
|
111
|
+
// Server package
|
|
112
|
+
await serverVariant.scaffold({
|
|
113
|
+
targetDir: dirs.server,
|
|
114
|
+
pkgName: names.server,
|
|
115
|
+
contractName: names.contract,
|
|
116
|
+
});
|
|
117
|
+
// Client package
|
|
118
|
+
await clientVariant.scaffold({
|
|
119
|
+
targetDir: dirs.client,
|
|
120
|
+
pkgName: names.client,
|
|
121
|
+
contractName: names.contract,
|
|
122
|
+
});
|
|
123
|
+
// Docker helper package (reuse docker variant)
|
|
124
|
+
await dockerVariant.scaffold({
|
|
125
|
+
targetDir: dirs.docker,
|
|
126
|
+
pkgName: names.docker,
|
|
127
|
+
});
|
|
438
128
|
},
|
|
439
129
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { baseTsConfig, writeFileIfMissing, } from '../shared.js';
|
|
1
|
+
import { BASE_LINT_DEV_DEPENDENCIES, basePackageFiles, basePackageJson, buildReadme, baseScripts, baseTsConfig, writeFileIfMissing, } from '../shared.js';
|
|
2
2
|
const CONTRACT_IMPORT_PLACEHOLDER = '@your-scope/contract';
|
|
3
|
-
function serverIndexTs(contractImport) {
|
|
3
|
+
export function serverIndexTs(contractImport) {
|
|
4
4
|
return `import 'dotenv/config'
|
|
5
5
|
import http from 'node:http'
|
|
6
6
|
import express from 'express'
|
|
@@ -46,23 +46,15 @@ server.listen(PORT, () => {
|
|
|
46
46
|
})
|
|
47
47
|
`;
|
|
48
48
|
}
|
|
49
|
-
function serverPackageJson(name) {
|
|
50
|
-
return
|
|
49
|
+
export function serverPackageJson(name, contractName = CONTRACT_IMPORT_PLACEHOLDER) {
|
|
50
|
+
return basePackageJson({
|
|
51
51
|
name,
|
|
52
|
-
version: '0.1.0',
|
|
53
52
|
private: false,
|
|
54
|
-
|
|
55
|
-
main: 'dist/index.js',
|
|
56
|
-
types: 'dist/index.d.ts',
|
|
57
|
-
files: ['dist'],
|
|
58
|
-
scripts: {
|
|
59
|
-
dev: 'node --loader ts-node/esm src/index.ts',
|
|
60
|
-
build: 'tsc -p tsconfig.json',
|
|
61
|
-
typecheck: 'tsc -p tsconfig.json --noEmit',
|
|
53
|
+
scripts: baseScripts('tsx watch --env-file .env src/index.ts', {
|
|
62
54
|
start: 'node dist/index.js',
|
|
63
|
-
},
|
|
55
|
+
}),
|
|
64
56
|
dependencies: {
|
|
65
|
-
|
|
57
|
+
[contractName]: 'workspace:*',
|
|
66
58
|
'@emeryld/rrroutes-server': '^2.4.1',
|
|
67
59
|
cors: '^2.8.5',
|
|
68
60
|
dotenv: '^16.4.5',
|
|
@@ -70,26 +62,59 @@ function serverPackageJson(name) {
|
|
|
70
62
|
zod: '^4.2.1',
|
|
71
63
|
},
|
|
72
64
|
devDependencies: {
|
|
65
|
+
...BASE_LINT_DEV_DEPENDENCIES,
|
|
73
66
|
'@types/cors': '^2.8.5',
|
|
74
67
|
'@types/express': '^5.0.6',
|
|
75
68
|
'@types/node': '^24.10.2',
|
|
76
|
-
'ts-node': '^10.9.2',
|
|
77
|
-
typescript: '^5.9.3',
|
|
78
69
|
},
|
|
79
|
-
}
|
|
70
|
+
});
|
|
80
71
|
}
|
|
81
72
|
function serverFiles(pkgName, contractImport) {
|
|
73
|
+
const scriptsForReadme = [
|
|
74
|
+
'dev',
|
|
75
|
+
'build',
|
|
76
|
+
'typecheck',
|
|
77
|
+
'lint',
|
|
78
|
+
'lint:fix',
|
|
79
|
+
'format',
|
|
80
|
+
'format:check',
|
|
81
|
+
'clean',
|
|
82
|
+
'test',
|
|
83
|
+
'start',
|
|
84
|
+
];
|
|
82
85
|
return {
|
|
83
|
-
'package.json': serverPackageJson(pkgName),
|
|
86
|
+
'package.json': serverPackageJson(pkgName, contractImport),
|
|
84
87
|
'tsconfig.json': baseTsConfig({ types: ['node'] }),
|
|
88
|
+
...basePackageFiles(),
|
|
85
89
|
'src/index.ts': serverIndexTs(contractImport),
|
|
86
90
|
'.env.example': 'PORT=4000\n',
|
|
87
|
-
'README.md':
|
|
88
|
-
|
|
89
|
-
Starter RRRoutes server scaffold.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
'README.md': buildReadme({
|
|
92
|
+
name: pkgName,
|
|
93
|
+
description: 'Starter RRRoutes server scaffold.',
|
|
94
|
+
scripts: scriptsForReadme,
|
|
95
|
+
sections: [
|
|
96
|
+
{
|
|
97
|
+
title: 'Getting Started',
|
|
98
|
+
lines: [
|
|
99
|
+
'- Install deps: `npm install` (or `pnpm install`)',
|
|
100
|
+
'- Copy `.env.example` to `.env` as needed.',
|
|
101
|
+
'- Start dev API: `npm run dev`',
|
|
102
|
+
'- Build output: `npm run build`',
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
title: 'Usage',
|
|
107
|
+
lines: [
|
|
108
|
+
`- Update the contract import in \`src/index.ts\` if needed (${contractImport}).`,
|
|
109
|
+
'- Start compiled server: `npm run start` after building.',
|
|
110
|
+
],
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
title: 'Environment',
|
|
114
|
+
lines: ['- `PORT` sets the HTTP port (default 4000).'],
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
}),
|
|
93
118
|
};
|
|
94
119
|
}
|
|
95
120
|
export const serverVariant = {
|
|
@@ -97,7 +122,8 @@ export const serverVariant = {
|
|
|
97
122
|
label: 'rrr server',
|
|
98
123
|
defaultDir: 'packages/rrr-server',
|
|
99
124
|
async scaffold(ctx) {
|
|
100
|
-
const
|
|
125
|
+
const contractImport = ctx.contractName ?? CONTRACT_IMPORT_PLACEHOLDER;
|
|
126
|
+
const files = serverFiles(ctx.pkgName, contractImport);
|
|
101
127
|
for (const [relative, contents] of Object.entries(files)) {
|
|
102
128
|
// eslint-disable-next-line no-await-in-loop
|
|
103
129
|
await writeFileIfMissing(ctx.targetDir, relative, contents);
|