@emeryld/manager 0.3.2 → 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 CHANGED
@@ -31,3 +31,28 @@ Interactive release helper for pnpm monorepos. Install it as a local dev depende
31
31
  ## Testing the loader registration
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
+
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.
@@ -202,7 +202,9 @@ async function postCreateTasks(targetDir) {
202
202
  async function gatherTarget() {
203
203
  const variant = await promptForVariant();
204
204
  const targetDir = await promptForTargetDir(variant.defaultDir);
205
- const pkgName = derivePackageName(targetDir);
205
+ const fallbackName = derivePackageName(targetDir);
206
+ const nameAnswer = await askLine(`Package name? (${fallbackName}): `);
207
+ const pkgName = (nameAnswer || fallbackName).trim() || fallbackName;
206
208
  await ensureTargetDir(targetDir);
207
209
  return { variant, targetDir, pkgName };
208
210
  }
@@ -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 `${JSON.stringify({
21
+ export function clientPackageJson(name, contractName = CONTRACT_IMPORT_PLACEHOLDER) {
22
+ return basePackageJson({
23
23
  name,
24
- version: '0.1.0',
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
- }, null, 2)}\n`;
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': `# ${pkgName}
52
-
53
- Starter RRRoutes client scaffold.
54
- - update the contract import in src/index.ts if needed (${contractImport})
55
- - the generated QueryClient is exported from src/index.ts
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 files = clientFiles(ctx.pkgName, CONTRACT_IMPORT_PLACEHOLDER);
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 `${JSON.stringify({
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
- files: ['dist'],
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
- typescript: '^5.9.3',
88
+ ...BASE_LINT_DEV_DEPENDENCIES,
97
89
  },
98
- }, null, 2)}\n`;
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': `# ${pkgName}
106
-
107
- Contract package scaffolded by manager-cli.
108
- - edit src/index.ts to add routes and socket events
109
- - build with \`npm run build\`
110
- - import the registry in your server/client packages
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,31 +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 `${JSON.stringify({
3
+ return basePackageJson({
4
4
  name,
5
- version: '0.1.0',
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',
8
+ 'docker:build': 'npm run docker:cli -- build',
9
+ 'docker:up': 'npm run docker:cli -- up',
10
+ 'docker:dev': 'npm run docker:cli -- dev',
11
+ 'docker:logs': 'npm run docker:cli -- logs',
12
+ 'docker:stop': 'npm run docker:cli -- stop',
13
+ 'docker:clean': 'npm run docker:cli -- clean',
14
+ 'docker:reset': 'npm run docker:cli -- reset',
15
+ }),
17
16
  dependencies: {
18
17
  cors: '^2.8.5',
19
18
  express: '^5.1.0',
20
19
  },
21
20
  devDependencies: {
21
+ ...BASE_LINT_DEV_DEPENDENCIES,
22
22
  '@types/cors': '^2.8.5',
23
23
  '@types/express': '^5.0.6',
24
24
  '@types/node': '^24.10.2',
25
- tsx: '^4.19.0',
26
- typescript: '^5.9.3',
25
+ 'docker-cli-js': '^3.0.9',
27
26
  },
28
- }, null, 2)}\n`;
27
+ });
29
28
  }
30
29
  function dockerIndexTs() {
31
30
  return `import express from 'express'
@@ -78,22 +77,146 @@ CMD ["node", "dist/index.js"]
78
77
  `;
79
78
  }
80
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
+ ];
81
99
  return {
82
100
  'package.json': dockerPackageJson(pkgName),
83
101
  'tsconfig.json': baseTsConfig({ types: ['node'] }),
84
102
  'src/index.ts': dockerIndexTs(),
103
+ 'scripts/docker.ts': dockerCliScript(pkgName),
85
104
  '.dockerignore': DOCKER_DOCKERIGNORE,
105
+ ...basePackageFiles(),
86
106
  Dockerfile: dockerDockerfile(),
87
- 'README.md': `# ${pkgName}
88
-
89
- Dockerized service scaffolded by manager-cli.
90
- - develop locally with \`npm run dev\`
91
- - build with \`npm run build\` and start with \`npm start\`
92
- - build/publish container: \`docker build -t ${pkgName}:latest .\`
93
- - run container: \`docker run -p 3000:3000 ${pkgName}:latest\`
94
- `,
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
+ }),
95
139
  };
96
140
  }
141
+ function dockerCliScript(pkgName) {
142
+ return `#!/usr/bin/env tsx
143
+ import { readFile } from 'node:fs/promises'
144
+ import path from 'node:path'
145
+ import { fileURLToPath } from 'node:url'
146
+ import { Docker } from 'docker-cli-js'
147
+
148
+ const __filename = fileURLToPath(import.meta.url)
149
+ const __dirname = path.dirname(__filename)
150
+ const pkgRaw = await readFile(path.join(__dirname, '..', 'package.json'), 'utf8')
151
+ const pkg = JSON.parse(pkgRaw) as { name?: string }
152
+ const image = \`\${pkg.name ?? '${pkgName}'}:latest\`
153
+ const container =
154
+ (pkg.name ?? '${pkgName}').replace(/[^a-z0-9]/gi, '-').replace(/^-+|-+$/g, '') || 'rrr-service'
155
+ const port = process.env.PORT ?? '3000'
156
+ const docker = new Docker({ spawnOptions: { stdio: 'inherit' } })
157
+
158
+ async function main() {
159
+ const [command = 'help'] = process.argv.slice(2)
160
+ if (command === 'help') return printHelp()
161
+ if (command === 'build') return docker.command(\`build -t \${image} .\`)
162
+ if (command === 'up') {
163
+ await docker.command(\`build -t \${image} .\`)
164
+ return docker.command(\`run -d --rm -p \${port}:\${port} --name \${container} \${image}\`)
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
+ }
171
+ if (command === 'run') {
172
+ return docker.command(\`run -d --rm -p \${port}:\${port} --name \${container} \${image}\`)
173
+ }
174
+ if (command === 'logs') return docker.command(\`logs -f \${container}\`)
175
+ if (command === 'stop') return docker.command(\`stop \${container}\`)
176
+ if (command === 'clean') {
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}\`))
185
+ return
186
+ }
187
+ return printHelp()
188
+ }
189
+
190
+ async function safe(run: () => Promise<unknown>) {
191
+ try {
192
+ await run()
193
+ } catch (error) {
194
+ console.warn(String(error))
195
+ }
196
+ }
197
+
198
+ function printHelp() {
199
+ console.log(
200
+ [
201
+ 'Docker helper commands:',
202
+ ' build -> docker build -t ${pkgName}:latest .',
203
+ ' up -> build then run in detached mode',
204
+ ' dev -> build, run, and tail logs',
205
+ ' run -> run existing image detached',
206
+ ' logs -> docker logs -f <container>',
207
+ ' stop -> docker stop <container>',
208
+ ' clean -> docker stop/rm <container>',
209
+ ' reset -> clean container and remove image',
210
+ ].join('\\n'),
211
+ )
212
+ }
213
+
214
+ main().catch((err) => {
215
+ console.error(err)
216
+ process.exit(1)
217
+ })
218
+ `;
219
+ }
97
220
  export const dockerVariant = {
98
221
  id: 'rrr-docker',
99
222
  label: 'dockerized service',
@@ -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 `${JSON.stringify({
3
+ return basePackageJson({
4
4
  name,
5
- version: '0.1.0',
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
- typescript: '^5.9.3',
7
+ ...BASE_LINT_DEV_DEPENDENCIES,
17
8
  },
18
- }, null, 2)}\n`;
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': `# ${pkgName}
26
-
27
- Empty package scaffolded by manager-cli.
28
- - edit src/index.ts to start coding
29
- - build with \`npm run build\`
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,204 +1,129 @@
1
- import { baseTsConfig, writeFileIfMissing, } from '../shared.js';
2
- function fullstackPackageJson(name) {
3
- return `${JSON.stringify({
4
- name,
5
- version: '0.1.0',
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
- type: 'module',
8
- main: 'dist/server/index.js',
9
- files: ['dist'],
32
+ useDefaults: false,
10
33
  scripts: {
11
- dev: 'concurrently "npm:dev:server" "npm:dev:client"',
12
- 'dev:server': 'tsx watch src/server/index.ts',
13
- 'dev:client': 'vite --host --port 5173',
14
- build: 'npm run build:server && npm run build:client',
15
- 'build:server': 'tsc -p tsconfig.server.json',
16
- 'build:client': 'vite build',
17
- start: 'node dist/server/index.js',
18
- typecheck: 'tsc -p tsconfig.json --noEmit',
19
- },
20
- dependencies: {
21
- cors: '^2.8.5',
22
- express: '^5.1.0',
23
- react: '^18.3.1',
24
- 'react-dom': '^18.3.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`,
25
51
  },
26
- devDependencies: {
27
- '@types/express': '^5.0.6',
28
- '@types/node': '^24.10.2',
29
- '@types/react': '^18.3.27',
30
- '@types/react-dom': '^18.3.7',
31
- '@vitejs/plugin-react': '^4.3.4',
32
- concurrently: '^8.2.0',
33
- tsx: '^4.19.0',
34
- typescript: '^5.9.3',
35
- vite: '^6.4.1',
52
+ devDependencies: { ...BASE_LINT_DEV_DEPENDENCIES },
53
+ extraFields: {
54
+ workspaces: ['packages/*'],
36
55
  },
37
- }, null, 2)}\n`;
38
- }
39
- function fullstackServerIndexTs() {
40
- return `import { existsSync } from 'node:fs'
41
- import path from 'node:path'
42
- import express from 'express'
43
- import cors from 'cors'
44
-
45
- const app = express()
46
- app.use(cors({ origin: '*' }))
47
- app.use(express.json())
48
-
49
- app.get('/api/health', (_req, res) => {
50
- res.json({ status: 'ok', at: new Date().toISOString() })
51
- })
52
-
53
- const clientDir = path.resolve(__dirname, '../client')
54
- if (existsSync(clientDir)) {
55
- app.use(express.static(clientDir))
56
- app.get('*', (_req, res) => {
57
- res.sendFile(path.join(clientDir, 'index.html'))
58
- })
59
- } else {
60
- console.warn('Client bundle missing; run "npm run build:client" to enable static assets.')
56
+ });
61
57
  }
62
-
63
- const PORT = Number.parseInt(process.env.PORT ?? '8080', 10)
64
-
65
- app.listen(PORT, () => {
66
- console.log(\`Full stack service running on http://localhost:\${PORT}\`)
67
- })
68
- `;
58
+ function rootPnpmWorkspace() {
59
+ return "packages:\n - 'packages/*'\n";
69
60
  }
70
- const FULLSTACK_APP_TSX = `import React from 'react'
61
+ function stackComposeYaml() {
62
+ return `services:
63
+ db:
64
+ image: postgres:15
65
+ restart: unless-stopped
66
+ environment:
67
+ POSTGRES_USER: postgres
68
+ POSTGRES_PASSWORD: postgres
69
+ POSTGRES_DB: rrroutes
70
+ ports:
71
+ - '5432:5432'
72
+ volumes:
73
+ - db-data:/var/lib/postgresql/data
71
74
 
72
- export function App() {
73
- return (
74
- <main style={{ fontFamily: 'Inter, system-ui, sans-serif', padding: 24 }}>
75
- <h1>Full stack service</h1>
76
- <p>Backend: <code>/api/health</code> responds with status + timestamp.</p>
77
- <p>Edit <code>src/client/App.tsx</code> to start building.</p>
78
- </main>
79
- )
80
- }
81
- `;
82
- const FULLSTACK_MAIN_TSX = `import React from 'react'
83
- import ReactDOM from 'react-dom/client'
84
- import { App } from './App'
85
- import './styles.css'
86
-
87
- ReactDOM.createRoot(document.getElementById('root')!).render(
88
- <React.StrictMode>
89
- <App />
90
- </React.StrictMode>,
91
- )
75
+ volumes:
76
+ db-data:
92
77
  `;
93
- const FULLSTACK_STYLES = `:root {
94
- background: radial-gradient(circle at 20% 20%, #eef2ff, #f7f8fb 45%);
95
- color: #0b1021;
96
78
  }
97
-
98
- body {
99
- margin: 0;
100
- }
101
- `;
102
- const FULLSTACK_INDEX_HTML = `<!doctype html>
103
- <html lang="en">
104
- <head>
105
- <meta charset="UTF-8" />
106
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
107
- <title>Full stack service</title>
108
- </head>
109
- <body>
110
- <div id="root"></div>
111
- <script type="module" src="/src/client/main.tsx"></script>
112
- </body>
113
- </html>
114
- `;
115
- function fullstackViteConfig() {
116
- return `import { defineConfig } from 'vite'
117
- import react from '@vitejs/plugin-react'
118
-
119
- export default defineConfig({
120
- plugins: [react()],
121
- build: {
122
- outDir: 'dist/client',
123
- },
124
- })
125
- `;
126
- }
127
- const FULLSTACK_DOCKERIGNORE = `node_modules
128
- dist
129
- .git
130
- .env
131
- npm-debug.log*
132
- pnpm-lock.yaml
133
- yarn.lock
134
- `;
135
- function fullstackDockerfile() {
136
- return `FROM node:20-slim AS builder
137
- WORKDIR /app
138
-
139
- COPY package*.json ./
140
- COPY pnpm-lock.yaml* ./
141
- RUN npm install
142
-
143
- COPY . .
144
- RUN npm run build
145
-
146
- FROM node:20-slim AS runner
147
- WORKDIR /app
148
- ENV NODE_ENV=production
149
-
150
- COPY --from=builder /app/package*.json ./
151
- COPY --from=builder /app/node_modules ./node_modules
152
- COPY --from=builder /app/dist ./dist
153
- RUN npm prune --omit=dev || true
154
-
155
- EXPOSE 8080
156
- CMD ["node", "dist/server/index.js"]
157
- `;
158
- }
159
- function fullstackFiles(pkgName) {
160
- return {
161
- 'package.json': fullstackPackageJson(pkgName),
79
+ async function scaffoldRootFiles(baseDir, baseName) {
80
+ const files = {
81
+ 'package.json': rootPackageJson(baseName),
82
+ 'pnpm-workspace.yaml': rootPnpmWorkspace(),
83
+ 'docker-compose.yml': stackComposeYaml(),
162
84
  'tsconfig.json': baseTsConfig({
163
- lib: ['ES2020', 'DOM'],
164
- types: ['node'],
165
- jsx: 'react-jsx',
166
- include: ['src/**/*', 'vite.config.ts'],
85
+ rootDir: '.',
86
+ outDir: 'dist',
87
+ include: ['packages/**/*', 'scripts/**/*', 'src/**/*'],
167
88
  }),
168
- 'tsconfig.server.json': baseTsConfig({
169
- types: ['node'],
170
- rootDir: 'src/server',
171
- outDir: 'dist/server',
172
- include: ['src/server/**/*'],
173
- }),
174
- 'vite.config.ts': fullstackViteConfig(),
175
- 'src/server/index.ts': fullstackServerIndexTs(),
176
- 'src/client/App.tsx': FULLSTACK_APP_TSX,
177
- 'src/client/main.tsx': FULLSTACK_MAIN_TSX,
178
- 'src/client/styles.css': FULLSTACK_STYLES,
179
- 'index.html': FULLSTACK_INDEX_HTML,
180
- '.dockerignore': FULLSTACK_DOCKERIGNORE,
181
- Dockerfile: fullstackDockerfile(),
182
- '.env.example': 'PORT=8080\n',
183
- 'README.md': `# ${pkgName}
184
-
185
- Full stack (API + Vite web) scaffolded by manager-cli.
186
- - dev: \`npm run dev\` (runs API + Vite client)
187
- - build: \`npm run build\` (server to dist/server, client to dist/client)
188
- - start: \`npm start\` (serves API and static client from dist)
189
- - docker: \`docker build -t ${pkgName}:latest .\` then \`docker run -p 8080:8080 ${pkgName}:latest\`
190
- `,
89
+ ...basePackageFiles(),
191
90
  };
91
+ for (const [relative, contents] of Object.entries(files)) {
92
+ // eslint-disable-next-line no-await-in-loop
93
+ await writeFileIfMissing(baseDir, relative, contents);
94
+ }
192
95
  }
193
96
  export const fullstackVariant = {
194
97
  id: 'rrr-fullstack',
195
- label: 'full stack service (api + web)',
196
- defaultDir: 'packages/rrr-fullstack',
98
+ label: 'rrr fullstack (contract + server + client + docker)',
99
+ defaultDir: 'rrrfull-stack',
197
100
  async scaffold(ctx) {
198
- const files = fullstackFiles(ctx.pkgName);
199
- for (const [relative, contents] of Object.entries(files)) {
200
- // eslint-disable-next-line no-await-in-loop
201
- await writeFileIfMissing(ctx.targetDir, relative, contents);
202
- }
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
+ });
203
128
  },
204
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 `${JSON.stringify({
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
- type: 'module',
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
- '@emeryld/rrroutes-contract': '^2.5.2',
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
- }, null, 2)}\n`;
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': `# ${pkgName}
88
-
89
- Starter RRRoutes server scaffold.
90
- - update the contract import in src/index.ts if needed (${contractImport})
91
- - run \`npm install\` then \`npm run dev\` to start the API
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 files = serverFiles(ctx.pkgName, CONTRACT_IMPORT_PLACEHOLDER);
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emeryld/manager",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Interactive manager for pnpm monorepos (update/test/build/publish).",
5
5
  "license": "MIT",
6
6
  "type": "module",