@emeryld/manager 0.2.4 → 0.3.1
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/dist/create-package/index.js +121 -0
- package/dist/create-package/shared.js +44 -0
- package/dist/create-package/variants/client.js +70 -0
- package/dist/create-package/variants/contract.js +125 -0
- package/dist/create-package/variants/docker.js +108 -0
- package/dist/create-package/variants/empty.js +44 -0
- package/dist/create-package/variants/fullstack.js +204 -0
- package/dist/create-package/variants/server.js +106 -0
- package/dist/create-package.js +388 -0
- package/dist/menu.js +2 -0
- package/dist/packages.js +1 -1
- package/dist/publish.js +24 -5
- package/package.json +1 -1
|
@@ -0,0 +1,121 @@
|
|
|
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 { askLine, promptSingleKey } from '../prompts.js';
|
|
5
|
+
import { colors, logGlobal } from '../utils/log.js';
|
|
6
|
+
import { workspaceRoot } from './shared.js';
|
|
7
|
+
import { clientVariant } from './variants/client.js';
|
|
8
|
+
import { contractVariant } from './variants/contract.js';
|
|
9
|
+
import { dockerVariant } from './variants/docker.js';
|
|
10
|
+
import { emptyVariant } from './variants/empty.js';
|
|
11
|
+
import { fullstackVariant } from './variants/fullstack.js';
|
|
12
|
+
import { serverVariant } from './variants/server.js';
|
|
13
|
+
const VARIANTS = [
|
|
14
|
+
contractVariant,
|
|
15
|
+
serverVariant,
|
|
16
|
+
clientVariant,
|
|
17
|
+
emptyVariant,
|
|
18
|
+
dockerVariant,
|
|
19
|
+
fullstackVariant,
|
|
20
|
+
];
|
|
21
|
+
function derivePackageName(targetDir) {
|
|
22
|
+
const base = path.basename(targetDir) || 'rrr-package';
|
|
23
|
+
return base;
|
|
24
|
+
}
|
|
25
|
+
async function ensureTargetDir(targetDir) {
|
|
26
|
+
try {
|
|
27
|
+
const stats = await stat(targetDir);
|
|
28
|
+
if (!stats.isDirectory()) {
|
|
29
|
+
throw new Error(`Target "${targetDir}" exists and is not a directory.`);
|
|
30
|
+
}
|
|
31
|
+
const entries = await readdir(targetDir);
|
|
32
|
+
if (entries.length > 0) {
|
|
33
|
+
logGlobal(`Target ${path.relative(workspaceRoot, targetDir)} is not empty; existing files will be preserved.`, colors.yellow);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (error &&
|
|
38
|
+
typeof error === 'object' &&
|
|
39
|
+
error.code === 'ENOENT') {
|
|
40
|
+
await mkdir(targetDir, { recursive: true });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async function runCommand(cmd, args, cwd = workspaceRoot) {
|
|
47
|
+
await new Promise((resolve, reject) => {
|
|
48
|
+
const child = spawn(cmd, args, {
|
|
49
|
+
cwd,
|
|
50
|
+
stdio: 'inherit',
|
|
51
|
+
shell: process.platform === 'win32',
|
|
52
|
+
});
|
|
53
|
+
child.on('exit', (code) => {
|
|
54
|
+
if (code === 0)
|
|
55
|
+
resolve();
|
|
56
|
+
else
|
|
57
|
+
reject(new Error(`${cmd} ${args.join(' ')} exited with ${code}`));
|
|
58
|
+
});
|
|
59
|
+
child.on('error', (err) => reject(err));
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
async function promptForVariant() {
|
|
63
|
+
const messageLines = [
|
|
64
|
+
'Pick a package template:',
|
|
65
|
+
VARIANTS.map((opt, idx) => ` [${idx + 1}] ${opt.label}`).join('\n'),
|
|
66
|
+
`Enter 1-${VARIANTS.length}: `,
|
|
67
|
+
];
|
|
68
|
+
const message = `${messageLines.join('\n')}`;
|
|
69
|
+
const variant = await promptSingleKey(message, (key) => {
|
|
70
|
+
const idx = Number.parseInt(key, 10);
|
|
71
|
+
if (Number.isInteger(idx) && idx >= 1 && idx <= VARIANTS.length) {
|
|
72
|
+
return VARIANTS[idx - 1];
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
});
|
|
76
|
+
return variant;
|
|
77
|
+
}
|
|
78
|
+
async function promptForTargetDir(fallback) {
|
|
79
|
+
const answer = await askLine(`Path for the new package? (${fallback}): `);
|
|
80
|
+
const normalized = answer || fallback;
|
|
81
|
+
return path.resolve(workspaceRoot, normalized);
|
|
82
|
+
}
|
|
83
|
+
async function postCreateTasks(targetDir) {
|
|
84
|
+
try {
|
|
85
|
+
logGlobal('Running pnpm install…', colors.cyan);
|
|
86
|
+
await runCommand('pnpm', ['install'], workspaceRoot);
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
logGlobal(`pnpm install failed: ${error instanceof Error ? error.message : String(error)}`, colors.yellow);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const pkgJsonPath = path.join(targetDir, 'package.json');
|
|
94
|
+
const pkgRaw = await readFile(pkgJsonPath, 'utf8');
|
|
95
|
+
const pkg = JSON.parse(pkgRaw);
|
|
96
|
+
if (pkg.scripts?.build) {
|
|
97
|
+
logGlobal('Running pnpm run build for the new package…', colors.cyan);
|
|
98
|
+
await runCommand('pnpm', ['-C', targetDir, 'run', 'build'], workspaceRoot);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
logGlobal(`Skipping build (could not read package.json): ${error instanceof Error ? error.message : String(error)}`, colors.yellow);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
async function gatherTarget() {
|
|
106
|
+
const variant = await promptForVariant();
|
|
107
|
+
const targetDir = await promptForTargetDir(variant.defaultDir);
|
|
108
|
+
const pkgName = derivePackageName(targetDir);
|
|
109
|
+
await ensureTargetDir(targetDir);
|
|
110
|
+
return { variant, targetDir, pkgName };
|
|
111
|
+
}
|
|
112
|
+
export async function createRrrPackage() {
|
|
113
|
+
const target = await gatherTarget();
|
|
114
|
+
logGlobal(`Creating ${target.variant.label} in ${path.relative(workspaceRoot, target.targetDir) || '.'}`, colors.green);
|
|
115
|
+
await target.variant.scaffold({
|
|
116
|
+
targetDir: target.targetDir,
|
|
117
|
+
pkgName: target.pkgName,
|
|
118
|
+
});
|
|
119
|
+
await postCreateTasks(target.targetDir);
|
|
120
|
+
logGlobal('Scaffold complete. Install/build steps were attempted; ready to run!', colors.green);
|
|
121
|
+
}
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { access, mkdir, readdir, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { askLine, promptSingleKey } from './prompts.js';
|
|
4
|
+
import { colors, logGlobal } from './utils/log.js';
|
|
5
|
+
const VARIANTS = [
|
|
6
|
+
{ id: 'rrr-contract', label: 'rrr contract', defaultDir: 'packages/rrr-contract' },
|
|
7
|
+
{ id: 'rrr-server', label: 'rrr server', defaultDir: 'packages/rrr-server' },
|
|
8
|
+
{ id: 'rrr-client', label: 'rrr client', defaultDir: 'packages/rrr-client' },
|
|
9
|
+
];
|
|
10
|
+
const workspaceRoot = process.cwd();
|
|
11
|
+
function derivePackageName(targetDir) {
|
|
12
|
+
const base = path.basename(targetDir) || 'rrr-package';
|
|
13
|
+
return base;
|
|
14
|
+
}
|
|
15
|
+
async function ensureTargetDir(targetDir) {
|
|
16
|
+
try {
|
|
17
|
+
const stats = await stat(targetDir);
|
|
18
|
+
if (!stats.isDirectory()) {
|
|
19
|
+
throw new Error(`Target "${targetDir}" exists and is not a directory.`);
|
|
20
|
+
}
|
|
21
|
+
const entries = await readdir(targetDir);
|
|
22
|
+
if (entries.length > 0) {
|
|
23
|
+
logGlobal(`Target ${path.relative(workspaceRoot, targetDir)} is not empty; existing files will be preserved.`, colors.yellow);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (error &&
|
|
28
|
+
typeof error === 'object' &&
|
|
29
|
+
error.code === 'ENOENT') {
|
|
30
|
+
await mkdir(targetDir, { recursive: true });
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async function writeFileIfMissing(baseDir, relative, contents) {
|
|
37
|
+
const fullPath = path.join(baseDir, relative);
|
|
38
|
+
await mkdir(path.dirname(fullPath), { recursive: true });
|
|
39
|
+
try {
|
|
40
|
+
await access(fullPath);
|
|
41
|
+
const rel = path.relative(workspaceRoot, fullPath);
|
|
42
|
+
console.log(` skipped ${rel} (already exists)`);
|
|
43
|
+
return 'skipped';
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (error &&
|
|
47
|
+
typeof error === 'object' &&
|
|
48
|
+
error.code !== 'ENOENT') {
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
await writeFile(fullPath, contents, 'utf8');
|
|
53
|
+
const rel = path.relative(workspaceRoot, fullPath);
|
|
54
|
+
console.log(` created ${rel}`);
|
|
55
|
+
return 'created';
|
|
56
|
+
}
|
|
57
|
+
function contractIndexTs() {
|
|
58
|
+
return `import { defineSocketEvents, finalize, resource } from '@emeryld/rrroutes-contract'
|
|
59
|
+
import { z } from 'zod'
|
|
60
|
+
|
|
61
|
+
const routes = resource('/api')
|
|
62
|
+
.sub(
|
|
63
|
+
resource('health')
|
|
64
|
+
.get({
|
|
65
|
+
outputSchema: z.object({
|
|
66
|
+
status: z.literal('ok'),
|
|
67
|
+
html: z.string().optional(),
|
|
68
|
+
}),
|
|
69
|
+
description: 'Basic GET health probe for uptime + docs.',
|
|
70
|
+
})
|
|
71
|
+
.post({
|
|
72
|
+
bodySchema: z.object({
|
|
73
|
+
echo: z.string().optional(),
|
|
74
|
+
}),
|
|
75
|
+
outputSchema: z.object({
|
|
76
|
+
status: z.literal('ok'),
|
|
77
|
+
received: z.string().optional(),
|
|
78
|
+
}),
|
|
79
|
+
description: 'POST health probe that echoes a payload.',
|
|
80
|
+
})
|
|
81
|
+
.done(),
|
|
82
|
+
)
|
|
83
|
+
.done()
|
|
84
|
+
|
|
85
|
+
export const registry = finalize(routes)
|
|
86
|
+
|
|
87
|
+
const sockets = defineSocketEvents(
|
|
88
|
+
{
|
|
89
|
+
joinMetaMessage: z.object({ room: z.string().optional() }),
|
|
90
|
+
leaveMetaMessage: z.object({ room: z.string().optional() }),
|
|
91
|
+
pingPayload: z.object({
|
|
92
|
+
note: z.string().default('ping'),
|
|
93
|
+
sentAt: z.string(),
|
|
94
|
+
}),
|
|
95
|
+
pongPayload: z.object({
|
|
96
|
+
ok: z.boolean(),
|
|
97
|
+
receivedAt: z.string(),
|
|
98
|
+
echo: z.string().optional(),
|
|
99
|
+
}),
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
'health:connected': {
|
|
103
|
+
message: z.object({
|
|
104
|
+
socketId: z.string(),
|
|
105
|
+
at: z.string(),
|
|
106
|
+
message: z.string(),
|
|
107
|
+
}),
|
|
108
|
+
},
|
|
109
|
+
'health:ping': {
|
|
110
|
+
message: z.object({
|
|
111
|
+
note: z.string().default('ping'),
|
|
112
|
+
}),
|
|
113
|
+
},
|
|
114
|
+
'health:pong': {
|
|
115
|
+
message: z.object({
|
|
116
|
+
ok: z.literal(true),
|
|
117
|
+
at: z.string(),
|
|
118
|
+
echo: z.string().optional(),
|
|
119
|
+
}),
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
export const socketConfig = sockets.config
|
|
125
|
+
export const socketEvents = sockets.events
|
|
126
|
+
export type AppRegistry = typeof registry
|
|
127
|
+
`;
|
|
128
|
+
}
|
|
129
|
+
function serverIndexTs(contractImport) {
|
|
130
|
+
return `import 'dotenv/config'
|
|
131
|
+
import http from 'node:http'
|
|
132
|
+
import express from 'express'
|
|
133
|
+
import cors from 'cors'
|
|
134
|
+
import { createRRRoute } from '@emeryld/rrroutes-server'
|
|
135
|
+
import { registry } from '${contractImport}'
|
|
136
|
+
|
|
137
|
+
const app = express()
|
|
138
|
+
app.use(cors({ origin: '*', credentials: true }))
|
|
139
|
+
app.use(express.json())
|
|
140
|
+
|
|
141
|
+
app.get('/', (_req, res) => {
|
|
142
|
+
res.send('<h1>rrr server ready</h1>')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
const routes = createRRRoute(app, {
|
|
146
|
+
buildCtx: async () => ({
|
|
147
|
+
requestId: Math.random().toString(36).slice(2),
|
|
148
|
+
}),
|
|
149
|
+
debug:
|
|
150
|
+
process.env.NODE_ENV === 'development'
|
|
151
|
+
? { request: true, handler: true }
|
|
152
|
+
: undefined,
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
routes.registerControllers(registry, {
|
|
156
|
+
'GET /api/health': {
|
|
157
|
+
handler: async ({ ctx }) => ({
|
|
158
|
+
out: {
|
|
159
|
+
status: 'ok',
|
|
160
|
+
requestId: ctx.requestId,
|
|
161
|
+
at: new Date().toISOString(),
|
|
162
|
+
},
|
|
163
|
+
}),
|
|
164
|
+
},
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
const PORT = Number.parseInt(process.env.PORT ?? '4000', 10)
|
|
168
|
+
const server = http.createServer(app)
|
|
169
|
+
|
|
170
|
+
server.listen(PORT, () => {
|
|
171
|
+
console.log(\`rrr server listening on http://localhost:\${PORT}\`)
|
|
172
|
+
})
|
|
173
|
+
`;
|
|
174
|
+
}
|
|
175
|
+
function clientIndexTs(contractImport) {
|
|
176
|
+
return `import { QueryClient } from '@tanstack/react-query'
|
|
177
|
+
import { createRouteClient } from '@emeryld/rrroutes-client'
|
|
178
|
+
import { registry } from '${contractImport}'
|
|
179
|
+
|
|
180
|
+
const baseUrl = process.env.RRR_API_URL ?? 'http://localhost:4000'
|
|
181
|
+
export const queryClient = new QueryClient()
|
|
182
|
+
|
|
183
|
+
export const routeClient = createRouteClient({
|
|
184
|
+
baseUrl,
|
|
185
|
+
queryClient,
|
|
186
|
+
environment: process.env.NODE_ENV === 'production' ? 'production' : 'development',
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
export const healthGet = routeClient.build(registry.byKey['GET /api/health'])
|
|
190
|
+
export const healthPost = routeClient.build(registry.byKey['POST /api/health'])
|
|
191
|
+
`;
|
|
192
|
+
}
|
|
193
|
+
function baseTsConfig(options) {
|
|
194
|
+
return `${JSON.stringify({
|
|
195
|
+
compilerOptions: {
|
|
196
|
+
target: 'ES2020',
|
|
197
|
+
module: 'NodeNext',
|
|
198
|
+
moduleResolution: 'NodeNext',
|
|
199
|
+
outDir: 'dist',
|
|
200
|
+
rootDir: 'src',
|
|
201
|
+
declaration: true,
|
|
202
|
+
sourceMap: true,
|
|
203
|
+
strict: true,
|
|
204
|
+
esModuleInterop: true,
|
|
205
|
+
skipLibCheck: true,
|
|
206
|
+
lib: options?.lib,
|
|
207
|
+
types: options?.types,
|
|
208
|
+
},
|
|
209
|
+
include: ['src/**/*'],
|
|
210
|
+
}, null, 2)}\n`;
|
|
211
|
+
}
|
|
212
|
+
function contractPackageJson(name) {
|
|
213
|
+
return `${JSON.stringify({
|
|
214
|
+
name,
|
|
215
|
+
version: '0.1.0',
|
|
216
|
+
private: false,
|
|
217
|
+
type: 'module',
|
|
218
|
+
main: 'dist/index.js',
|
|
219
|
+
types: 'dist/index.d.ts',
|
|
220
|
+
exports: {
|
|
221
|
+
'.': {
|
|
222
|
+
types: './dist/index.d.ts',
|
|
223
|
+
import: './dist/index.js',
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
files: ['dist'],
|
|
227
|
+
scripts: {
|
|
228
|
+
build: 'tsc -p tsconfig.json',
|
|
229
|
+
typecheck: 'tsc -p tsconfig.json --noEmit',
|
|
230
|
+
},
|
|
231
|
+
dependencies: {
|
|
232
|
+
'@emeryld/rrroutes-contract': '^2.5.2',
|
|
233
|
+
zod: '^4.2.1',
|
|
234
|
+
},
|
|
235
|
+
devDependencies: {
|
|
236
|
+
typescript: '^5.9.3',
|
|
237
|
+
},
|
|
238
|
+
}, null, 2)}\n`;
|
|
239
|
+
}
|
|
240
|
+
function serverPackageJson(name) {
|
|
241
|
+
return `${JSON.stringify({
|
|
242
|
+
name,
|
|
243
|
+
version: '0.1.0',
|
|
244
|
+
private: false,
|
|
245
|
+
type: 'module',
|
|
246
|
+
main: 'dist/index.js',
|
|
247
|
+
types: 'dist/index.d.ts',
|
|
248
|
+
files: ['dist'],
|
|
249
|
+
scripts: {
|
|
250
|
+
dev: 'node --loader ts-node/esm src/index.ts',
|
|
251
|
+
build: 'tsc -p tsconfig.json',
|
|
252
|
+
typecheck: 'tsc -p tsconfig.json --noEmit',
|
|
253
|
+
start: 'node dist/index.js',
|
|
254
|
+
},
|
|
255
|
+
dependencies: {
|
|
256
|
+
'@emeryld/rrroutes-contract': '^2.5.2',
|
|
257
|
+
'@emeryld/rrroutes-server': '^2.4.1',
|
|
258
|
+
cors: '^2.8.5',
|
|
259
|
+
dotenv: '^16.4.5',
|
|
260
|
+
express: '^5.1.0',
|
|
261
|
+
zod: '^4.2.1',
|
|
262
|
+
},
|
|
263
|
+
devDependencies: {
|
|
264
|
+
'@types/cors': '^2.8.5',
|
|
265
|
+
'@types/express': '^5.0.6',
|
|
266
|
+
'@types/node': '^24.10.2',
|
|
267
|
+
'ts-node': '^10.9.2',
|
|
268
|
+
typescript: '^5.9.3',
|
|
269
|
+
},
|
|
270
|
+
}, null, 2)}\n`;
|
|
271
|
+
}
|
|
272
|
+
function clientPackageJson(name) {
|
|
273
|
+
return `${JSON.stringify({
|
|
274
|
+
name,
|
|
275
|
+
version: '0.1.0',
|
|
276
|
+
private: true,
|
|
277
|
+
type: 'module',
|
|
278
|
+
main: 'dist/index.js',
|
|
279
|
+
types: 'dist/index.d.ts',
|
|
280
|
+
files: ['dist'],
|
|
281
|
+
scripts: {
|
|
282
|
+
build: 'tsc -p tsconfig.json',
|
|
283
|
+
typecheck: 'tsc -p tsconfig.json --noEmit',
|
|
284
|
+
},
|
|
285
|
+
dependencies: {
|
|
286
|
+
'@emeryld/rrroutes-client': '^2.5.3',
|
|
287
|
+
'@emeryld/rrroutes-contract': '^2.5.2',
|
|
288
|
+
'@tanstack/react-query': '^5.90.12',
|
|
289
|
+
'socket.io-client': '^4.8.3',
|
|
290
|
+
},
|
|
291
|
+
devDependencies: {
|
|
292
|
+
'@types/node': '^24.10.2',
|
|
293
|
+
typescript: '^5.9.3',
|
|
294
|
+
},
|
|
295
|
+
}, null, 2)}\n`;
|
|
296
|
+
}
|
|
297
|
+
function contractFiles(pkgName) {
|
|
298
|
+
return {
|
|
299
|
+
'package.json': contractPackageJson(pkgName),
|
|
300
|
+
'tsconfig.json': baseTsConfig(),
|
|
301
|
+
'src/index.ts': contractIndexTs(),
|
|
302
|
+
'README.md': `# ${pkgName}
|
|
303
|
+
|
|
304
|
+
Contract package scaffolded by manager-cli.
|
|
305
|
+
- edit src/index.ts to add routes and socket events
|
|
306
|
+
- build with \`npm run build\`
|
|
307
|
+
- import the registry in your server/client packages
|
|
308
|
+
`,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
function serverFiles(pkgName, contractImport) {
|
|
312
|
+
return {
|
|
313
|
+
'package.json': serverPackageJson(pkgName),
|
|
314
|
+
'tsconfig.json': baseTsConfig({ types: ['node'] }),
|
|
315
|
+
'src/index.ts': serverIndexTs(contractImport),
|
|
316
|
+
'.env.example': 'PORT=4000\n',
|
|
317
|
+
'README.md': `# ${pkgName}
|
|
318
|
+
|
|
319
|
+
Starter RRRoutes server scaffold.
|
|
320
|
+
- update the contract import in src/index.ts if needed (${contractImport})
|
|
321
|
+
- run \`npm install\` then \`npm run dev\` to start the API
|
|
322
|
+
`,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
function clientFiles(pkgName, contractImport) {
|
|
326
|
+
return {
|
|
327
|
+
'package.json': clientPackageJson(pkgName),
|
|
328
|
+
'tsconfig.json': baseTsConfig({ lib: ['ES2020', 'DOM'], types: ['node'] }),
|
|
329
|
+
'src/index.ts': clientIndexTs(contractImport),
|
|
330
|
+
'README.md': `# ${pkgName}
|
|
331
|
+
|
|
332
|
+
Starter RRRoutes client scaffold.
|
|
333
|
+
- update the contract import in src/index.ts if needed (${contractImport})
|
|
334
|
+
- the generated QueryClient is exported from src/index.ts
|
|
335
|
+
`,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
function resolveContractImport(variant) {
|
|
339
|
+
if (variant === 'rrr-contract')
|
|
340
|
+
return '';
|
|
341
|
+
return '@your-scope/contract';
|
|
342
|
+
}
|
|
343
|
+
async function scaffoldVariant(variant, targetDir, pkgName) {
|
|
344
|
+
const contractImport = resolveContractImport(variant);
|
|
345
|
+
let files;
|
|
346
|
+
if (variant === 'rrr-contract')
|
|
347
|
+
files = contractFiles(pkgName);
|
|
348
|
+
else if (variant === 'rrr-server')
|
|
349
|
+
files = serverFiles(pkgName, contractImport);
|
|
350
|
+
else
|
|
351
|
+
files = clientFiles(pkgName, contractImport);
|
|
352
|
+
for (const [relative, contents] of Object.entries(files)) {
|
|
353
|
+
// eslint-disable-next-line no-await-in-loop
|
|
354
|
+
await writeFileIfMissing(targetDir, relative, contents);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
async function promptForVariant() {
|
|
358
|
+
const messageLines = [
|
|
359
|
+
'Pick a package template:',
|
|
360
|
+
VARIANTS.map((opt, idx) => ` [${idx + 1}] ${opt.label}`).join('\n'),
|
|
361
|
+
'Enter 1, 2, or 3: ',
|
|
362
|
+
];
|
|
363
|
+
const message = `${messageLines.join('\n')}`;
|
|
364
|
+
const variant = await promptSingleKey(message, (key) => {
|
|
365
|
+
if (key === '1')
|
|
366
|
+
return VARIANTS[0];
|
|
367
|
+
if (key === '2')
|
|
368
|
+
return VARIANTS[1];
|
|
369
|
+
if (key === '3')
|
|
370
|
+
return VARIANTS[2];
|
|
371
|
+
return undefined;
|
|
372
|
+
});
|
|
373
|
+
return variant;
|
|
374
|
+
}
|
|
375
|
+
async function promptForTargetDir(fallback) {
|
|
376
|
+
const answer = await askLine(`Path for the new package? (${fallback}): `);
|
|
377
|
+
const normalized = answer || fallback;
|
|
378
|
+
return path.resolve(workspaceRoot, normalized);
|
|
379
|
+
}
|
|
380
|
+
export async function createRrrPackage() {
|
|
381
|
+
const variant = await promptForVariant();
|
|
382
|
+
const targetDir = await promptForTargetDir(variant.defaultDir);
|
|
383
|
+
await ensureTargetDir(targetDir);
|
|
384
|
+
const pkgName = derivePackageName(targetDir);
|
|
385
|
+
logGlobal(`Creating ${variant.label} in ${path.relative(workspaceRoot, targetDir) || '.'}`, colors.green);
|
|
386
|
+
await scaffoldVariant(variant.id, targetDir, pkgName);
|
|
387
|
+
logGlobal('Scaffold complete. Install deps and start building!', colors.green);
|
|
388
|
+
}
|
package/dist/menu.js
CHANGED
package/dist/packages.js
CHANGED
|
@@ -37,7 +37,7 @@ function deriveSubstitute(name) {
|
|
|
37
37
|
return '';
|
|
38
38
|
const segments = trimmed.split(/[@\/\-]/).filter(Boolean);
|
|
39
39
|
const transformed = segments
|
|
40
|
-
.map((segment) => segment
|
|
40
|
+
.map((segment) => segment)
|
|
41
41
|
.filter(Boolean)
|
|
42
42
|
.join(' ');
|
|
43
43
|
return transformed || trimmed;
|
package/dist/publish.js
CHANGED
|
@@ -5,6 +5,8 @@ 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/index.js';
|
|
9
|
+
import { colors, logGlobal } from './utils/log.js';
|
|
8
10
|
function resolveTargetsFromArg(packages, arg) {
|
|
9
11
|
if (arg.toLowerCase() === 'all')
|
|
10
12
|
return getOrderedPackages(packages);
|
|
@@ -89,14 +91,27 @@ function optsFromParsed(p) {
|
|
|
89
91
|
}
|
|
90
92
|
async function runPackageSelectionLoop(packages, helperArgs) {
|
|
91
93
|
let argv = [...helperArgs];
|
|
94
|
+
let currentPackages = packages;
|
|
92
95
|
// eslint-disable-next-line no-constant-condition
|
|
93
96
|
while (true) {
|
|
94
97
|
let lastStep;
|
|
95
98
|
await runHelperCli({
|
|
96
99
|
title: 'Pick one of the packages or all',
|
|
97
|
-
scripts:
|
|
98
|
-
|
|
99
|
-
|
|
100
|
+
scripts: [
|
|
101
|
+
...buildPackageSelectionMenu(currentPackages, (step) => {
|
|
102
|
+
lastStep = step;
|
|
103
|
+
}),
|
|
104
|
+
{
|
|
105
|
+
name: 'Create package',
|
|
106
|
+
emoji: '✨',
|
|
107
|
+
description: 'Scaffold a new rrr package (contract/server/client)',
|
|
108
|
+
handler: async () => {
|
|
109
|
+
await createRrrPackage();
|
|
110
|
+
currentPackages = await loadPackages();
|
|
111
|
+
lastStep = 'back';
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
],
|
|
100
115
|
argv, // pass through CLI args only once; subsequent loops rely on selection
|
|
101
116
|
});
|
|
102
117
|
argv = [];
|
|
@@ -108,10 +123,11 @@ async function main() {
|
|
|
108
123
|
const cliArgs = process.argv.slice(2);
|
|
109
124
|
const parsed = parseCliArgs(cliArgs);
|
|
110
125
|
const packages = await loadPackages();
|
|
111
|
-
if (packages.length === 0)
|
|
112
|
-
throw new Error('No packages found in ./packages');
|
|
113
126
|
// If user provided non-interactive flags, run headless path
|
|
114
127
|
if (parsed.nonInteractive) {
|
|
128
|
+
if (packages.length === 0) {
|
|
129
|
+
throw new Error('No packages found in ./packages');
|
|
130
|
+
}
|
|
115
131
|
publishCliState.autoConfirmAll = true;
|
|
116
132
|
if (!parsed.selectionArg) {
|
|
117
133
|
throw new Error('Non-interactive mode requires a package selection: <pkg> or "all".');
|
|
@@ -128,6 +144,9 @@ async function main() {
|
|
|
128
144
|
await releaseSingle(targets[0], packages, opts);
|
|
129
145
|
return;
|
|
130
146
|
}
|
|
147
|
+
if (packages.length === 0) {
|
|
148
|
+
logGlobal('No packages found in ./packages. Use "Create package" to scaffold one.', colors.yellow);
|
|
149
|
+
}
|
|
131
150
|
// Interactive flow (unchanged): selection menu then step menu
|
|
132
151
|
if (parsed.selectionArg) {
|
|
133
152
|
const targets = resolveTargetsFromArg(packages, parsed.selectionArg);
|