@emeryld/manager 0.3.0 → 0.3.2

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.
@@ -0,0 +1,218 @@
1
+ import { mkdir, readdir, readFile, stat } from 'node:fs/promises';
2
+ import { spawn } from 'node:child_process';
3
+ import path from 'node:path';
4
+ import { stdin as input } from 'node:process';
5
+ import { askLine, promptSingleKey } from '../prompts.js';
6
+ import { colors, logGlobal } from '../utils/log.js';
7
+ import { workspaceRoot } from './shared.js';
8
+ import { clientVariant } from './variants/client.js';
9
+ import { contractVariant } from './variants/contract.js';
10
+ import { dockerVariant } from './variants/docker.js';
11
+ import { emptyVariant } from './variants/empty.js';
12
+ import { fullstackVariant } from './variants/fullstack.js';
13
+ import { serverVariant } from './variants/server.js';
14
+ const VARIANTS = [
15
+ contractVariant,
16
+ serverVariant,
17
+ clientVariant,
18
+ emptyVariant,
19
+ dockerVariant,
20
+ fullstackVariant,
21
+ ];
22
+ function derivePackageName(targetDir) {
23
+ const base = path.basename(targetDir) || 'rrr-package';
24
+ return base;
25
+ }
26
+ async function ensureTargetDir(targetDir) {
27
+ try {
28
+ const stats = await stat(targetDir);
29
+ if (!stats.isDirectory()) {
30
+ throw new Error(`Target "${targetDir}" exists and is not a directory.`);
31
+ }
32
+ const entries = await readdir(targetDir);
33
+ if (entries.length > 0) {
34
+ logGlobal(`Target ${path.relative(workspaceRoot, targetDir)} is not empty; existing files will be preserved.`, colors.yellow);
35
+ }
36
+ }
37
+ catch (error) {
38
+ if (error &&
39
+ typeof error === 'object' &&
40
+ error.code === 'ENOENT') {
41
+ await mkdir(targetDir, { recursive: true });
42
+ return;
43
+ }
44
+ throw error;
45
+ }
46
+ }
47
+ async function runCommand(cmd, args, cwd = workspaceRoot) {
48
+ await new Promise((resolve, reject) => {
49
+ const child = spawn(cmd, args, {
50
+ cwd,
51
+ stdio: 'inherit',
52
+ shell: process.platform === 'win32',
53
+ });
54
+ child.on('exit', (code) => {
55
+ if (code === 0)
56
+ resolve();
57
+ else
58
+ reject(new Error(`${cmd} ${args.join(' ')} exited with ${code}`));
59
+ });
60
+ child.on('error', (err) => reject(err));
61
+ });
62
+ }
63
+ function formatVariantLines(variants, selected) {
64
+ const heading = colors.magenta('Available templates');
65
+ const lines = [heading];
66
+ variants.forEach((variant, index) => {
67
+ const isSelected = index === selected;
68
+ const pointer = isSelected ? `${colors.green('➤')} ` : ' ';
69
+ const numberLabel = colors.cyan(String(index + 1).padStart(2, ' '));
70
+ const label = isSelected ? colors.green(variant.label) : variant.label;
71
+ const meta = colors.dim(variant.defaultDir);
72
+ lines.push(`${pointer}${numberLabel}. ${label} ${meta}`);
73
+ });
74
+ lines.push('');
75
+ lines.push(colors.dim('Use ↑/↓ (or j/k) to move, digits (1-9,0 for 10) to pick, Enter to confirm, Esc/Ctrl+C to exit.'));
76
+ return lines;
77
+ }
78
+ function renderInteractiveList(lines, previousLineCount) {
79
+ if (previousLineCount > 0) {
80
+ process.stdout.write(`\x1b[${previousLineCount}A`);
81
+ process.stdout.write('\x1b[0J');
82
+ }
83
+ lines.forEach((line) => console.log(line));
84
+ return lines.length;
85
+ }
86
+ async function promptForVariant() {
87
+ const supportsRawMode = typeof input.setRawMode === 'function' && input.isTTY;
88
+ if (!supportsRawMode) {
89
+ const fallbackMessage = [
90
+ 'Pick a package template:',
91
+ VARIANTS.map((opt, idx) => ` [${idx + 1}] ${opt.label}`).join('\n'),
92
+ `Enter 1-${VARIANTS.length}: `,
93
+ ].join('\n');
94
+ return promptSingleKey(fallbackMessage, (key) => {
95
+ const idx = Number.parseInt(key, 10);
96
+ if (Number.isInteger(idx) && idx >= 1 && idx <= VARIANTS.length) {
97
+ return VARIANTS[idx - 1];
98
+ }
99
+ return undefined;
100
+ });
101
+ }
102
+ const wasRaw = input.isRaw;
103
+ if (!wasRaw) {
104
+ input.setRawMode(true);
105
+ input.resume();
106
+ }
107
+ process.stdout.write('\x1b[?25l');
108
+ return new Promise((resolve) => {
109
+ let selectedIndex = 0;
110
+ let renderedLines = 0;
111
+ const cleanup = () => {
112
+ if (renderedLines > 0) {
113
+ process.stdout.write(`\x1b[${renderedLines}A`);
114
+ process.stdout.write('\x1b[0J');
115
+ renderedLines = 0;
116
+ }
117
+ process.stdout.write('\x1b[?25h');
118
+ if (!wasRaw) {
119
+ input.setRawMode(false);
120
+ input.pause();
121
+ }
122
+ input.removeListener('data', onData);
123
+ };
124
+ const commitSelection = (variant) => {
125
+ cleanup();
126
+ console.log();
127
+ resolve(variant);
128
+ };
129
+ const render = () => {
130
+ const lines = formatVariantLines(VARIANTS, selectedIndex);
131
+ renderedLines = renderInteractiveList(lines, renderedLines);
132
+ };
133
+ const onData = (buffer) => {
134
+ const isArrowUp = buffer.equals(Buffer.from([0x1b, 0x5b, 0x41]));
135
+ const isArrowDown = buffer.equals(Buffer.from([0x1b, 0x5b, 0x42]));
136
+ const isCtrlC = buffer.length === 1 && buffer[0] === 0x03;
137
+ const isEnter = buffer.length === 1 && (buffer[0] === 0x0d || buffer[0] === 0x0a);
138
+ const isEscape = buffer.length === 1 && buffer[0] === 0x1b;
139
+ if (isCtrlC || isEscape) {
140
+ cleanup();
141
+ process.exit(1);
142
+ }
143
+ if (isArrowUp ||
144
+ (buffer.length === 1 && (buffer[0] === 0x6b || buffer[0] === 0x4b))) {
145
+ selectedIndex = (selectedIndex - 1 + VARIANTS.length) % VARIANTS.length;
146
+ render();
147
+ return;
148
+ }
149
+ if (isArrowDown ||
150
+ (buffer.length === 1 && (buffer[0] === 0x6a || buffer[0] === 0x4a))) {
151
+ selectedIndex = (selectedIndex + 1) % VARIANTS.length;
152
+ render();
153
+ return;
154
+ }
155
+ if (isEnter) {
156
+ commitSelection(VARIANTS[selectedIndex]);
157
+ return;
158
+ }
159
+ if (buffer.length === 1 && buffer[0] >= 0x30 && buffer[0] <= 0x39) {
160
+ const numericValue = buffer[0] === 0x30 ? 10 : buffer[0] - 0x30;
161
+ const idx = numericValue - 1;
162
+ if (idx >= 0 && idx < VARIANTS.length) {
163
+ commitSelection(VARIANTS[idx]);
164
+ }
165
+ else {
166
+ process.stdout.write('\x07');
167
+ }
168
+ return;
169
+ }
170
+ };
171
+ input.on('data', onData);
172
+ render();
173
+ });
174
+ }
175
+ async function promptForTargetDir(fallback) {
176
+ const answer = await askLine(`Path for the new package? (${fallback}): `);
177
+ const normalized = answer || fallback;
178
+ return path.resolve(workspaceRoot, normalized);
179
+ }
180
+ async function postCreateTasks(targetDir) {
181
+ try {
182
+ logGlobal('Running pnpm install…', colors.cyan);
183
+ await runCommand('pnpm', ['install'], workspaceRoot);
184
+ }
185
+ catch (error) {
186
+ logGlobal(`pnpm install failed: ${error instanceof Error ? error.message : String(error)}`, colors.yellow);
187
+ return;
188
+ }
189
+ try {
190
+ const pkgJsonPath = path.join(targetDir, 'package.json');
191
+ const pkgRaw = await readFile(pkgJsonPath, 'utf8');
192
+ const pkg = JSON.parse(pkgRaw);
193
+ if (pkg.scripts?.build) {
194
+ logGlobal('Running pnpm run build for the new package…', colors.cyan);
195
+ await runCommand('pnpm', ['-C', targetDir, 'run', 'build'], workspaceRoot);
196
+ }
197
+ }
198
+ catch (error) {
199
+ logGlobal(`Skipping build (could not read package.json): ${error instanceof Error ? error.message : String(error)}`, colors.yellow);
200
+ }
201
+ }
202
+ async function gatherTarget() {
203
+ const variant = await promptForVariant();
204
+ const targetDir = await promptForTargetDir(variant.defaultDir);
205
+ const pkgName = derivePackageName(targetDir);
206
+ await ensureTargetDir(targetDir);
207
+ return { variant, targetDir, pkgName };
208
+ }
209
+ export async function createRrrPackage() {
210
+ const target = await gatherTarget();
211
+ logGlobal(`Creating ${target.variant.label} in ${path.relative(workspaceRoot, target.targetDir) || '.'}`, colors.green);
212
+ await target.variant.scaffold({
213
+ targetDir: target.targetDir,
214
+ pkgName: target.pkgName,
215
+ });
216
+ await postCreateTasks(target.targetDir);
217
+ logGlobal('Scaffold complete. Install/build steps were attempted; ready to run!', colors.green);
218
+ }
@@ -0,0 +1,44 @@
1
+ import { access, mkdir, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ export const workspaceRoot = process.cwd();
4
+ export async function writeFileIfMissing(baseDir, relative, contents) {
5
+ const fullPath = path.join(baseDir, relative);
6
+ await mkdir(path.dirname(fullPath), { recursive: true });
7
+ try {
8
+ await access(fullPath);
9
+ const rel = path.relative(workspaceRoot, fullPath);
10
+ console.log(` skipped ${rel} (already exists)`);
11
+ return 'skipped';
12
+ }
13
+ catch (error) {
14
+ if (error &&
15
+ typeof error === 'object' &&
16
+ error.code !== 'ENOENT') {
17
+ throw error;
18
+ }
19
+ }
20
+ await writeFile(fullPath, contents, 'utf8');
21
+ const rel = path.relative(workspaceRoot, fullPath);
22
+ console.log(` created ${rel}`);
23
+ return 'created';
24
+ }
25
+ export function baseTsConfig(options) {
26
+ return `${JSON.stringify({
27
+ compilerOptions: {
28
+ target: 'ES2020',
29
+ module: 'NodeNext',
30
+ moduleResolution: 'NodeNext',
31
+ outDir: options?.outDir ?? 'dist',
32
+ rootDir: options?.rootDir ?? 'src',
33
+ declaration: true,
34
+ sourceMap: true,
35
+ strict: true,
36
+ esModuleInterop: true,
37
+ skipLibCheck: true,
38
+ lib: options?.lib,
39
+ types: options?.types,
40
+ jsx: options?.jsx,
41
+ },
42
+ include: options?.include ?? ['src/**/*'],
43
+ }, null, 2)}\n`;
44
+ }
@@ -0,0 +1,70 @@
1
+ import { baseTsConfig, writeFileIfMissing, } from '../shared.js';
2
+ const CONTRACT_IMPORT_PLACEHOLDER = '@your-scope/contract';
3
+ function clientIndexTs(contractImport) {
4
+ return `import { QueryClient } from '@tanstack/react-query'
5
+ import { createRouteClient } from '@emeryld/rrroutes-client'
6
+ import { registry } from '${contractImport}'
7
+
8
+ const baseUrl = process.env.RRR_API_URL ?? 'http://localhost:4000'
9
+ export const queryClient = new QueryClient()
10
+
11
+ export const routeClient = createRouteClient({
12
+ baseUrl,
13
+ queryClient,
14
+ environment: process.env.NODE_ENV === 'production' ? 'production' : 'development',
15
+ })
16
+
17
+ export const healthGet = routeClient.build(registry.byKey['GET /api/health'])
18
+ export const healthPost = routeClient.build(registry.byKey['POST /api/health'])
19
+ `;
20
+ }
21
+ function clientPackageJson(name) {
22
+ return `${JSON.stringify({
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
+ },
34
+ dependencies: {
35
+ '@emeryld/rrroutes-client': '^2.5.3',
36
+ '@emeryld/rrroutes-contract': '^2.5.2',
37
+ '@tanstack/react-query': '^5.90.12',
38
+ 'socket.io-client': '^4.8.3',
39
+ },
40
+ devDependencies: {
41
+ '@types/node': '^24.10.2',
42
+ typescript: '^5.9.3',
43
+ },
44
+ }, null, 2)}\n`;
45
+ }
46
+ function clientFiles(pkgName, contractImport) {
47
+ return {
48
+ 'package.json': clientPackageJson(pkgName),
49
+ 'tsconfig.json': baseTsConfig({ lib: ['ES2020', 'DOM'], types: ['node'] }),
50
+ '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
+ `,
57
+ };
58
+ }
59
+ export const clientVariant = {
60
+ id: 'rrr-client',
61
+ label: 'rrr client',
62
+ defaultDir: 'packages/rrr-client',
63
+ async scaffold(ctx) {
64
+ const files = clientFiles(ctx.pkgName, CONTRACT_IMPORT_PLACEHOLDER);
65
+ for (const [relative, contents] of Object.entries(files)) {
66
+ // eslint-disable-next-line no-await-in-loop
67
+ await writeFileIfMissing(ctx.targetDir, relative, contents);
68
+ }
69
+ },
70
+ };
@@ -0,0 +1,125 @@
1
+ import { baseTsConfig, writeFileIfMissing, } from '../shared.js';
2
+ const CONTRACT_TS = `import { defineSocketEvents, finalize, resource } from '@emeryld/rrroutes-contract'
3
+ import { z } from 'zod'
4
+
5
+ const routes = resource('/api')
6
+ .sub(
7
+ resource('health')
8
+ .get({
9
+ outputSchema: z.object({
10
+ status: z.literal('ok'),
11
+ html: z.string().optional(),
12
+ }),
13
+ description: 'Basic GET health probe for uptime + docs.',
14
+ })
15
+ .post({
16
+ bodySchema: z.object({
17
+ echo: z.string().optional(),
18
+ }),
19
+ outputSchema: z.object({
20
+ status: z.literal('ok'),
21
+ received: z.string().optional(),
22
+ }),
23
+ description: 'POST health probe that echoes a payload.',
24
+ })
25
+ .done(),
26
+ )
27
+ .done()
28
+
29
+ export const registry = finalize(routes)
30
+
31
+ const sockets = defineSocketEvents(
32
+ {
33
+ joinMetaMessage: z.object({ room: z.string().optional() }),
34
+ leaveMetaMessage: z.object({ room: z.string().optional() }),
35
+ pingPayload: z.object({
36
+ note: z.string().default('ping'),
37
+ sentAt: z.string(),
38
+ }),
39
+ pongPayload: z.object({
40
+ ok: z.boolean(),
41
+ receivedAt: z.string(),
42
+ echo: z.string().optional(),
43
+ }),
44
+ },
45
+ {
46
+ 'health:connected': {
47
+ message: z.object({
48
+ socketId: z.string(),
49
+ at: z.string(),
50
+ message: z.string(),
51
+ }),
52
+ },
53
+ 'health:ping': {
54
+ message: z.object({
55
+ note: z.string().default('ping'),
56
+ }),
57
+ },
58
+ 'health:pong': {
59
+ message: z.object({
60
+ ok: z.literal(true),
61
+ at: z.string(),
62
+ echo: z.string().optional(),
63
+ }),
64
+ },
65
+ },
66
+ )
67
+
68
+ export const socketConfig = sockets.config
69
+ export const socketEvents = sockets.events
70
+ export type AppRegistry = typeof registry
71
+ `;
72
+ function contractPackageJson(name) {
73
+ return `${JSON.stringify({
74
+ name,
75
+ version: '0.1.0',
76
+ private: false,
77
+ type: 'module',
78
+ main: 'dist/index.js',
79
+ types: 'dist/index.d.ts',
80
+ exports: {
81
+ '.': {
82
+ types: './dist/index.d.ts',
83
+ import: './dist/index.js',
84
+ },
85
+ },
86
+ files: ['dist'],
87
+ scripts: {
88
+ build: 'tsc -p tsconfig.json',
89
+ typecheck: 'tsc -p tsconfig.json --noEmit',
90
+ },
91
+ dependencies: {
92
+ '@emeryld/rrroutes-contract': '^2.5.2',
93
+ zod: '^4.2.1',
94
+ },
95
+ devDependencies: {
96
+ typescript: '^5.9.3',
97
+ },
98
+ }, null, 2)}\n`;
99
+ }
100
+ function contractFiles(pkgName) {
101
+ return {
102
+ 'package.json': contractPackageJson(pkgName),
103
+ 'tsconfig.json': baseTsConfig(),
104
+ '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
+ `,
112
+ };
113
+ }
114
+ export const contractVariant = {
115
+ id: 'rrr-contract',
116
+ label: 'rrr contract',
117
+ defaultDir: 'packages/rrr-contract',
118
+ async scaffold(ctx) {
119
+ const files = contractFiles(ctx.pkgName);
120
+ for (const [relative, contents] of Object.entries(files)) {
121
+ // eslint-disable-next-line no-await-in-loop
122
+ await writeFileIfMissing(ctx.targetDir, relative, contents);
123
+ }
124
+ },
125
+ };
@@ -0,0 +1,108 @@
1
+ import { baseTsConfig, writeFileIfMissing, } from '../shared.js';
2
+ function dockerPackageJson(name) {
3
+ return `${JSON.stringify({
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',
15
+ start: 'node dist/index.js',
16
+ },
17
+ dependencies: {
18
+ cors: '^2.8.5',
19
+ express: '^5.1.0',
20
+ },
21
+ devDependencies: {
22
+ '@types/cors': '^2.8.5',
23
+ '@types/express': '^5.0.6',
24
+ '@types/node': '^24.10.2',
25
+ tsx: '^4.19.0',
26
+ typescript: '^5.9.3',
27
+ },
28
+ }, null, 2)}\n`;
29
+ }
30
+ function dockerIndexTs() {
31
+ return `import express from 'express'
32
+ import cors from 'cors'
33
+
34
+ const app = express()
35
+ app.use(cors({ origin: '*' }))
36
+ app.use(express.json())
37
+
38
+ app.get('/api/health', (_req, res) => {
39
+ res.json({ status: 'ok', at: new Date().toISOString() })
40
+ })
41
+
42
+ const PORT = Number.parseInt(process.env.PORT ?? '3000', 10)
43
+
44
+ app.listen(PORT, () => {
45
+ console.log(\`Docker-ready service listening on http://localhost:\${PORT}\`)
46
+ })
47
+ `;
48
+ }
49
+ const DOCKER_DOCKERIGNORE = `node_modules
50
+ dist
51
+ .git
52
+ .env
53
+ npm-debug.log*
54
+ pnpm-lock.yaml
55
+ yarn.lock
56
+ `;
57
+ function dockerDockerfile() {
58
+ return `FROM node:20-slim AS builder
59
+ WORKDIR /app
60
+
61
+ COPY package*.json ./
62
+ COPY pnpm-lock.yaml* ./
63
+ RUN npm install
64
+
65
+ COPY . .
66
+ RUN npm run build
67
+
68
+ FROM node:20-slim AS runner
69
+ WORKDIR /app
70
+ ENV NODE_ENV=production
71
+
72
+ COPY --from=builder /app/package*.json ./
73
+ COPY --from=builder /app/node_modules ./node_modules
74
+ COPY --from=builder /app/dist ./dist
75
+
76
+ EXPOSE 3000
77
+ CMD ["node", "dist/index.js"]
78
+ `;
79
+ }
80
+ function dockerFiles(pkgName) {
81
+ return {
82
+ 'package.json': dockerPackageJson(pkgName),
83
+ 'tsconfig.json': baseTsConfig({ types: ['node'] }),
84
+ 'src/index.ts': dockerIndexTs(),
85
+ '.dockerignore': DOCKER_DOCKERIGNORE,
86
+ 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
+ `,
95
+ };
96
+ }
97
+ export const dockerVariant = {
98
+ id: 'rrr-docker',
99
+ label: 'dockerized service',
100
+ defaultDir: 'packages/rrr-docker',
101
+ async scaffold(ctx) {
102
+ const files = dockerFiles(ctx.pkgName);
103
+ for (const [relative, contents] of Object.entries(files)) {
104
+ // eslint-disable-next-line no-await-in-loop
105
+ await writeFileIfMissing(ctx.targetDir, relative, contents);
106
+ }
107
+ },
108
+ };
@@ -0,0 +1,44 @@
1
+ import { baseTsConfig, writeFileIfMissing, } from '../shared.js';
2
+ function emptyPackageJson(name) {
3
+ return `${JSON.stringify({
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
+ },
15
+ devDependencies: {
16
+ typescript: '^5.9.3',
17
+ },
18
+ }, null, 2)}\n`;
19
+ }
20
+ function emptyFiles(pkgName) {
21
+ return {
22
+ 'package.json': emptyPackageJson(pkgName),
23
+ 'tsconfig.json': baseTsConfig({ types: ['node'] }),
24
+ '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
+ `,
31
+ };
32
+ }
33
+ export const emptyVariant = {
34
+ id: 'rrr-empty',
35
+ label: 'empty package',
36
+ defaultDir: 'packages/rrr-empty',
37
+ async scaffold(ctx) {
38
+ const files = emptyFiles(ctx.pkgName);
39
+ for (const [relative, contents] of Object.entries(files)) {
40
+ // eslint-disable-next-line no-await-in-loop
41
+ await writeFileIfMissing(ctx.targetDir, relative, contents);
42
+ }
43
+ },
44
+ };
@@ -0,0 +1,204 @@
1
+ import { baseTsConfig, writeFileIfMissing, } from '../shared.js';
2
+ function fullstackPackageJson(name) {
3
+ return `${JSON.stringify({
4
+ name,
5
+ version: '0.1.0',
6
+ private: true,
7
+ type: 'module',
8
+ main: 'dist/server/index.js',
9
+ files: ['dist'],
10
+ 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',
25
+ },
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',
36
+ },
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.')
61
+ }
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
+ `;
69
+ }
70
+ const FULLSTACK_APP_TSX = `import React from 'react'
71
+
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
+ )
92
+ `;
93
+ const FULLSTACK_STYLES = `:root {
94
+ background: radial-gradient(circle at 20% 20%, #eef2ff, #f7f8fb 45%);
95
+ color: #0b1021;
96
+ }
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),
162
+ 'tsconfig.json': baseTsConfig({
163
+ lib: ['ES2020', 'DOM'],
164
+ types: ['node'],
165
+ jsx: 'react-jsx',
166
+ include: ['src/**/*', 'vite.config.ts'],
167
+ }),
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
+ `,
191
+ };
192
+ }
193
+ export const fullstackVariant = {
194
+ id: 'rrr-fullstack',
195
+ label: 'full stack service (api + web)',
196
+ defaultDir: 'packages/rrr-fullstack',
197
+ 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
+ }
203
+ },
204
+ };
@@ -0,0 +1,106 @@
1
+ import { baseTsConfig, writeFileIfMissing, } from '../shared.js';
2
+ const CONTRACT_IMPORT_PLACEHOLDER = '@your-scope/contract';
3
+ function serverIndexTs(contractImport) {
4
+ return `import 'dotenv/config'
5
+ import http from 'node:http'
6
+ import express from 'express'
7
+ import cors from 'cors'
8
+ import { createRRRoute } from '@emeryld/rrroutes-server'
9
+ import { registry } from '${contractImport}'
10
+
11
+ const app = express()
12
+ app.use(cors({ origin: '*', credentials: true }))
13
+ app.use(express.json())
14
+
15
+ app.get('/', (_req, res) => {
16
+ res.send('<h1>rrr server ready</h1>')
17
+ })
18
+
19
+ const routes = createRRRoute(app, {
20
+ buildCtx: async () => ({
21
+ requestId: Math.random().toString(36).slice(2),
22
+ }),
23
+ debug:
24
+ process.env.NODE_ENV === 'development'
25
+ ? { request: true, handler: true }
26
+ : undefined,
27
+ })
28
+
29
+ routes.registerControllers(registry, {
30
+ 'GET /api/health': {
31
+ handler: async ({ ctx }) => ({
32
+ out: {
33
+ status: 'ok',
34
+ requestId: ctx.requestId,
35
+ at: new Date().toISOString(),
36
+ },
37
+ }),
38
+ },
39
+ })
40
+
41
+ const PORT = Number.parseInt(process.env.PORT ?? '4000', 10)
42
+ const server = http.createServer(app)
43
+
44
+ server.listen(PORT, () => {
45
+ console.log(\`rrr server listening on http://localhost:\${PORT}\`)
46
+ })
47
+ `;
48
+ }
49
+ function serverPackageJson(name) {
50
+ return `${JSON.stringify({
51
+ name,
52
+ version: '0.1.0',
53
+ 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',
62
+ start: 'node dist/index.js',
63
+ },
64
+ dependencies: {
65
+ '@emeryld/rrroutes-contract': '^2.5.2',
66
+ '@emeryld/rrroutes-server': '^2.4.1',
67
+ cors: '^2.8.5',
68
+ dotenv: '^16.4.5',
69
+ express: '^5.1.0',
70
+ zod: '^4.2.1',
71
+ },
72
+ devDependencies: {
73
+ '@types/cors': '^2.8.5',
74
+ '@types/express': '^5.0.6',
75
+ '@types/node': '^24.10.2',
76
+ 'ts-node': '^10.9.2',
77
+ typescript: '^5.9.3',
78
+ },
79
+ }, null, 2)}\n`;
80
+ }
81
+ function serverFiles(pkgName, contractImport) {
82
+ return {
83
+ 'package.json': serverPackageJson(pkgName),
84
+ 'tsconfig.json': baseTsConfig({ types: ['node'] }),
85
+ 'src/index.ts': serverIndexTs(contractImport),
86
+ '.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
+ `,
93
+ };
94
+ }
95
+ export const serverVariant = {
96
+ id: 'rrr-server',
97
+ label: 'rrr server',
98
+ defaultDir: 'packages/rrr-server',
99
+ async scaffold(ctx) {
100
+ const files = serverFiles(ctx.pkgName, CONTRACT_IMPORT_PLACEHOLDER);
101
+ for (const [relative, contents] of Object.entries(files)) {
102
+ // eslint-disable-next-line no-await-in-loop
103
+ await writeFileIfMissing(ctx.targetDir, relative, contents);
104
+ }
105
+ },
106
+ };
package/dist/publish.js CHANGED
@@ -5,7 +5,7 @@ import { getOrderedPackages, loadPackages, resolvePackage } from './packages.js'
5
5
  import { releaseMultiple, releaseSingle, } from './release.js';
6
6
  import { ensureWorkingTreeCommitted } from './preflight.js';
7
7
  import { publishCliState } from './prompts.js';
8
- import { createRrrPackage } from './create-package.js';
8
+ import { createRrrPackage } from './create-package/index.js';
9
9
  import { colors, logGlobal } from './utils/log.js';
10
10
  function resolveTargetsFromArg(packages, arg) {
11
11
  if (arg.toLowerCase() === 'all')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emeryld/manager",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Interactive manager for pnpm monorepos (update/test/build/publish).",
5
5
  "license": "MIT",
6
6
  "type": "module",