@emeryld/manager 0.3.1 → 0.3.3
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 +11 -0
- package/dist/create-package/index.js +113 -14
- package/dist/create-package/variants/docker.js +70 -2
- package/dist/create-package/variants/fullstack.js +247 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -31,3 +31,14 @@ 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 variants
|
|
36
|
+
|
|
37
|
+
Use `Create package` inside the CLI to scaffold a starter workspace package (the flow auto-installs deps and builds when a `build` script exists). Templates:
|
|
38
|
+
|
|
39
|
+
- **rrr contract** – Shared RRRoutes registry and socket config for server/client packages. Exported registry lives in `src/index.ts`.
|
|
40
|
+
- **rrr server** – Express + RRRoutes API wired to the contract placeholder, `dev` via ts-node, `.env.example` included.
|
|
41
|
+
- **rrr client** – Backend-agnostic RRRoutes client helper with React Query setup in `src/index.ts`.
|
|
42
|
+
- **empty package** – Minimal TypeScript library with build/typecheck scripts.
|
|
43
|
+
- **dockerized service** – Express health API with Dockerfile, .dockerignore, and Docker helper scripts (`docker:build`, `docker:up`, `docker:logs`, `docker:stop`, `docker:clean`) powered by `docker-cli-js`.
|
|
44
|
+
- **full stack service (api + web)** – Express API + Vite React client, unified build/start, Dockerfile + helper scripts (same `docker:*` commands as above).
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdir, readdir, readFile, stat } from 'node:fs/promises';
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { stdin as input } from 'node:process';
|
|
4
5
|
import { askLine, promptSingleKey } from '../prompts.js';
|
|
5
6
|
import { colors, logGlobal } from '../utils/log.js';
|
|
6
7
|
import { workspaceRoot } from './shared.js';
|
|
@@ -59,21 +60,117 @@ async function runCommand(cmd, args, cwd = workspaceRoot) {
|
|
|
59
60
|
child.on('error', (err) => reject(err));
|
|
60
61
|
});
|
|
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
|
+
}
|
|
62
86
|
async function promptForVariant() {
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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();
|
|
75
173
|
});
|
|
76
|
-
return variant;
|
|
77
174
|
}
|
|
78
175
|
async function promptForTargetDir(fallback) {
|
|
79
176
|
const answer = await askLine(`Path for the new package? (${fallback}): `);
|
|
@@ -105,7 +202,9 @@ async function postCreateTasks(targetDir) {
|
|
|
105
202
|
async function gatherTarget() {
|
|
106
203
|
const variant = await promptForVariant();
|
|
107
204
|
const targetDir = await promptForTargetDir(variant.defaultDir);
|
|
108
|
-
const
|
|
205
|
+
const fallbackName = derivePackageName(targetDir);
|
|
206
|
+
const nameAnswer = await askLine(`Package name? (${fallbackName}): `);
|
|
207
|
+
const pkgName = (nameAnswer || fallbackName).trim() || fallbackName;
|
|
109
208
|
await ensureTargetDir(targetDir);
|
|
110
209
|
return { variant, targetDir, pkgName };
|
|
111
210
|
}
|
|
@@ -13,6 +13,12 @@ function dockerPackageJson(name) {
|
|
|
13
13
|
build: 'tsc -p tsconfig.json',
|
|
14
14
|
typecheck: 'tsc -p tsconfig.json --noEmit',
|
|
15
15
|
start: 'node dist/index.js',
|
|
16
|
+
'docker:cli': 'tsx scripts/docker.ts',
|
|
17
|
+
'docker:build': 'npm run docker:cli -- build',
|
|
18
|
+
'docker:up': 'npm run docker:cli -- up',
|
|
19
|
+
'docker:logs': 'npm run docker:cli -- logs',
|
|
20
|
+
'docker:stop': 'npm run docker:cli -- stop',
|
|
21
|
+
'docker:clean': 'npm run docker:cli -- clean',
|
|
16
22
|
},
|
|
17
23
|
dependencies: {
|
|
18
24
|
cors: '^2.8.5',
|
|
@@ -22,6 +28,7 @@ function dockerPackageJson(name) {
|
|
|
22
28
|
'@types/cors': '^2.8.5',
|
|
23
29
|
'@types/express': '^5.0.6',
|
|
24
30
|
'@types/node': '^24.10.2',
|
|
31
|
+
'docker-cli-js': '^3.0.9',
|
|
25
32
|
tsx: '^4.19.0',
|
|
26
33
|
typescript: '^5.9.3',
|
|
27
34
|
},
|
|
@@ -82,6 +89,7 @@ function dockerFiles(pkgName) {
|
|
|
82
89
|
'package.json': dockerPackageJson(pkgName),
|
|
83
90
|
'tsconfig.json': baseTsConfig({ types: ['node'] }),
|
|
84
91
|
'src/index.ts': dockerIndexTs(),
|
|
92
|
+
'scripts/docker.ts': dockerCliScript(pkgName),
|
|
85
93
|
'.dockerignore': DOCKER_DOCKERIGNORE,
|
|
86
94
|
Dockerfile: dockerDockerfile(),
|
|
87
95
|
'README.md': `# ${pkgName}
|
|
@@ -89,11 +97,71 @@ function dockerFiles(pkgName) {
|
|
|
89
97
|
Dockerized service scaffolded by manager-cli.
|
|
90
98
|
- develop locally with \`npm run dev\`
|
|
91
99
|
- build with \`npm run build\` and start with \`npm start\`
|
|
92
|
-
-
|
|
93
|
-
- run container: \`docker run -p 3000:3000 ${pkgName}:latest\`
|
|
100
|
+
- docker helper: \`npm run docker:up\` (build + run), \`npm run docker:logs\`, \`npm run docker:stop\`
|
|
94
101
|
`,
|
|
95
102
|
};
|
|
96
103
|
}
|
|
104
|
+
function dockerCliScript(pkgName) {
|
|
105
|
+
return `#!/usr/bin/env tsx
|
|
106
|
+
import { readFile } from 'node:fs/promises'
|
|
107
|
+
import path from 'node:path'
|
|
108
|
+
import { fileURLToPath } from 'node:url'
|
|
109
|
+
import { Docker } from 'docker-cli-js'
|
|
110
|
+
|
|
111
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
112
|
+
const __dirname = path.dirname(__filename)
|
|
113
|
+
const pkgRaw = await readFile(path.join(__dirname, '..', 'package.json'), 'utf8')
|
|
114
|
+
const pkg = JSON.parse(pkgRaw) as { name?: string }
|
|
115
|
+
const image = \`\${pkg.name ?? '${pkgName}'}:latest\`
|
|
116
|
+
const container =
|
|
117
|
+
(pkg.name ?? '${pkgName}').replace(/[^a-z0-9]/gi, '-').replace(/^-+|-+$/g, '') || 'rrr-service'
|
|
118
|
+
const port = process.env.PORT ?? '3000'
|
|
119
|
+
const docker = new Docker({ spawnOptions: { stdio: 'inherit' } })
|
|
120
|
+
|
|
121
|
+
async function main() {
|
|
122
|
+
const [command = 'help'] = process.argv.slice(2)
|
|
123
|
+
if (command === 'help') return printHelp()
|
|
124
|
+
if (command === 'build') return docker.command(\`build -t \${image} .\`)
|
|
125
|
+
if (command === 'up') {
|
|
126
|
+
await docker.command(\`build -t \${image} .\`)
|
|
127
|
+
return docker.command(\`run -d --rm -p \${port}:\${port} --name \${container} \${image}\`)
|
|
128
|
+
}
|
|
129
|
+
if (command === 'run') {
|
|
130
|
+
return docker.command(\`run -d --rm -p \${port}:\${port} --name \${container} \${image}\`)
|
|
131
|
+
}
|
|
132
|
+
if (command === 'logs') return docker.command(\`logs -f \${container}\`)
|
|
133
|
+
if (command === 'stop') return docker.command(\`stop \${container}\`)
|
|
134
|
+
if (command === 'clean') {
|
|
135
|
+
try {
|
|
136
|
+
await docker.command(\`rm -f \${container}\`)
|
|
137
|
+
} catch (error) {
|
|
138
|
+
console.warn(String(error))
|
|
139
|
+
}
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
return printHelp()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function printHelp() {
|
|
146
|
+
console.log(
|
|
147
|
+
[
|
|
148
|
+
'Docker helper commands:',
|
|
149
|
+
' build -> docker build -t ${pkgName}:latest .',
|
|
150
|
+
' up -> build then run in detached mode',
|
|
151
|
+
' run -> run existing image detached',
|
|
152
|
+
' logs -> docker logs -f <container>',
|
|
153
|
+
' stop -> docker stop <container>',
|
|
154
|
+
' clean -> docker rm -f <container>',
|
|
155
|
+
].join('\\n'),
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
main().catch((err) => {
|
|
160
|
+
console.error(err)
|
|
161
|
+
process.exit(1)
|
|
162
|
+
})
|
|
163
|
+
`;
|
|
164
|
+
}
|
|
97
165
|
export const dockerVariant = {
|
|
98
166
|
id: 'rrr-docker',
|
|
99
167
|
label: 'dockerized service',
|
|
@@ -16,20 +16,36 @@ function fullstackPackageJson(name) {
|
|
|
16
16
|
'build:client': 'vite build',
|
|
17
17
|
start: 'node dist/server/index.js',
|
|
18
18
|
typecheck: 'tsc -p tsconfig.json --noEmit',
|
|
19
|
+
'docker:cli': 'tsx scripts/docker.ts',
|
|
20
|
+
'docker:build': 'npm run docker:cli -- build',
|
|
21
|
+
'docker:up': 'npm run docker:cli -- up',
|
|
22
|
+
'docker:logs': 'npm run docker:cli -- logs',
|
|
23
|
+
'docker:stop': 'npm run docker:cli -- stop',
|
|
24
|
+
'docker:clean': 'npm run docker:cli -- clean',
|
|
25
|
+
'db:up': 'docker compose up -d db',
|
|
26
|
+
'db:down': 'docker compose down',
|
|
19
27
|
},
|
|
20
28
|
dependencies: {
|
|
29
|
+
'@emeryld/rrroutes-client': '^2.5.3',
|
|
30
|
+
'@emeryld/rrroutes-contract': '^2.5.2',
|
|
31
|
+
'@emeryld/rrroutes-server': '^2.4.1',
|
|
32
|
+
'@tanstack/react-query': '^5.90.12',
|
|
21
33
|
cors: '^2.8.5',
|
|
22
34
|
express: '^5.1.0',
|
|
35
|
+
pg: '^8.13.1',
|
|
23
36
|
react: '^18.3.1',
|
|
24
37
|
'react-dom': '^18.3.1',
|
|
38
|
+
zod: '^4.2.1',
|
|
25
39
|
},
|
|
26
40
|
devDependencies: {
|
|
27
41
|
'@types/express': '^5.0.6',
|
|
28
42
|
'@types/node': '^24.10.2',
|
|
29
43
|
'@types/react': '^18.3.27',
|
|
30
44
|
'@types/react-dom': '^18.3.7',
|
|
45
|
+
'@types/pg': '^8.11.10',
|
|
31
46
|
'@vitejs/plugin-react': '^4.3.4',
|
|
32
47
|
concurrently: '^8.2.0',
|
|
48
|
+
'docker-cli-js': '^3.0.9',
|
|
33
49
|
tsx: '^4.19.0',
|
|
34
50
|
typescript: '^5.9.3',
|
|
35
51
|
vite: '^6.4.1',
|
|
@@ -41,13 +57,49 @@ function fullstackServerIndexTs() {
|
|
|
41
57
|
import path from 'node:path'
|
|
42
58
|
import express from 'express'
|
|
43
59
|
import cors from 'cors'
|
|
60
|
+
import { Pool } from 'pg'
|
|
61
|
+
import { createRRRoute } from '@emeryld/rrroutes-server'
|
|
62
|
+
import { registry } from '../contract/index.js'
|
|
44
63
|
|
|
45
64
|
const app = express()
|
|
46
65
|
app.use(cors({ origin: '*' }))
|
|
47
66
|
app.use(express.json())
|
|
48
67
|
|
|
49
|
-
|
|
50
|
-
|
|
68
|
+
const pool = new Pool({
|
|
69
|
+
connectionString:
|
|
70
|
+
process.env.DATABASE_URL ??
|
|
71
|
+
'postgresql://postgres:postgres@localhost:5432/rrroutes',
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const routes = createRRRoute(app, {
|
|
75
|
+
buildCtx: async () => ({
|
|
76
|
+
requestId: Math.random().toString(36).slice(2),
|
|
77
|
+
}),
|
|
78
|
+
debug:
|
|
79
|
+
process.env.NODE_ENV === 'development'
|
|
80
|
+
? { request: true, handler: true }
|
|
81
|
+
: undefined,
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
routes.registerControllers(registry, {
|
|
85
|
+
'GET /api/health': {
|
|
86
|
+
handler: async () => {
|
|
87
|
+
let db = 'skipped'
|
|
88
|
+
try {
|
|
89
|
+
await pool.query('select 1')
|
|
90
|
+
db = 'ok'
|
|
91
|
+
} catch (error) {
|
|
92
|
+
db = 'error: ' + String(error)
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
out: {
|
|
96
|
+
status: 'ok',
|
|
97
|
+
at: new Date().toISOString(),
|
|
98
|
+
db,
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
},
|
|
51
103
|
})
|
|
52
104
|
|
|
53
105
|
const clientDir = path.resolve(__dirname, '../client')
|
|
@@ -68,14 +120,43 @@ app.listen(PORT, () => {
|
|
|
68
120
|
`;
|
|
69
121
|
}
|
|
70
122
|
const FULLSTACK_APP_TSX = `import React from 'react'
|
|
123
|
+
import { QueryClientProvider } from '@tanstack/react-query'
|
|
124
|
+
import { queryClient, healthGet } from './client/api'
|
|
125
|
+
|
|
126
|
+
function HealthCard() {
|
|
127
|
+
const health = healthGet.useEndpoint()
|
|
128
|
+
return (
|
|
129
|
+
<section
|
|
130
|
+
style={{
|
|
131
|
+
background: '#fff',
|
|
132
|
+
padding: 16,
|
|
133
|
+
borderRadius: 12,
|
|
134
|
+
boxShadow: '0 8px 24px rgba(12, 18, 32, 0.05)',
|
|
135
|
+
border: '1px solid #e5e7eb',
|
|
136
|
+
}}
|
|
137
|
+
>
|
|
138
|
+
<h2>Health</h2>
|
|
139
|
+
<button onClick={() => health.refetch()}>Ping API</button>
|
|
140
|
+
<pre style={{ whiteSpace: 'pre-wrap' }}>
|
|
141
|
+
{JSON.stringify(health.data ?? { note: 'Waiting for request...' }, null, 2)}
|
|
142
|
+
</pre>
|
|
143
|
+
</section>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
71
146
|
|
|
72
147
|
export function App() {
|
|
73
148
|
return (
|
|
74
|
-
<
|
|
75
|
-
<
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
149
|
+
<QueryClientProvider client={queryClient}>
|
|
150
|
+
<main style={{ fontFamily: 'Inter, system-ui, sans-serif', padding: 24 }}>
|
|
151
|
+
<h1>Full stack service</h1>
|
|
152
|
+
<p>
|
|
153
|
+
Contract-driven API + Vite client. Health endpoint uses RRRoutes and checks the
|
|
154
|
+
Postgres connection.
|
|
155
|
+
</p>
|
|
156
|
+
<p>Edit <code>src/contract/index.ts</code> or <code>src/server/index.ts</code> to extend the API.</p>
|
|
157
|
+
<HealthCard />
|
|
158
|
+
</main>
|
|
159
|
+
</QueryClientProvider>
|
|
79
160
|
)
|
|
80
161
|
}
|
|
81
162
|
`;
|
|
@@ -132,6 +213,22 @@ npm-debug.log*
|
|
|
132
213
|
pnpm-lock.yaml
|
|
133
214
|
yarn.lock
|
|
134
215
|
`;
|
|
216
|
+
const FULLSTACK_DOCKER_COMPOSE = `services:
|
|
217
|
+
db:
|
|
218
|
+
image: postgres:15
|
|
219
|
+
restart: unless-stopped
|
|
220
|
+
environment:
|
|
221
|
+
POSTGRES_USER: postgres
|
|
222
|
+
POSTGRES_PASSWORD: postgres
|
|
223
|
+
POSTGRES_DB: rrroutes
|
|
224
|
+
ports:
|
|
225
|
+
- '5432:5432'
|
|
226
|
+
volumes:
|
|
227
|
+
- db-data:/var/lib/postgresql/data
|
|
228
|
+
|
|
229
|
+
volumes:
|
|
230
|
+
db-data:
|
|
231
|
+
`;
|
|
135
232
|
function fullstackDockerfile() {
|
|
136
233
|
return `FROM node:20-slim AS builder
|
|
137
234
|
WORKDIR /app
|
|
@@ -167,29 +264,167 @@ function fullstackFiles(pkgName) {
|
|
|
167
264
|
}),
|
|
168
265
|
'tsconfig.server.json': baseTsConfig({
|
|
169
266
|
types: ['node'],
|
|
170
|
-
rootDir: 'src
|
|
171
|
-
outDir: 'dist
|
|
172
|
-
include: ['src/server/**/*'],
|
|
267
|
+
rootDir: 'src',
|
|
268
|
+
outDir: 'dist',
|
|
269
|
+
include: ['src/server/**/*', 'src/contract/**/*'],
|
|
173
270
|
}),
|
|
174
271
|
'vite.config.ts': fullstackViteConfig(),
|
|
272
|
+
'src/contract/index.ts': fullstackContractTs(),
|
|
175
273
|
'src/server/index.ts': fullstackServerIndexTs(),
|
|
274
|
+
'src/server/env.d.ts': "declare namespace NodeJS { interface ProcessEnv { DATABASE_URL?: string; PORT?: string } }\n",
|
|
275
|
+
'src/client/api.ts': fullstackClientApi(),
|
|
176
276
|
'src/client/App.tsx': FULLSTACK_APP_TSX,
|
|
177
277
|
'src/client/main.tsx': FULLSTACK_MAIN_TSX,
|
|
178
278
|
'src/client/styles.css': FULLSTACK_STYLES,
|
|
179
279
|
'index.html': FULLSTACK_INDEX_HTML,
|
|
280
|
+
'scripts/docker.ts': fullstackDockerCliScript(pkgName),
|
|
180
281
|
'.dockerignore': FULLSTACK_DOCKERIGNORE,
|
|
282
|
+
'docker-compose.yml': FULLSTACK_DOCKER_COMPOSE,
|
|
181
283
|
Dockerfile: fullstackDockerfile(),
|
|
182
|
-
'.env.example': 'PORT=8080\n',
|
|
284
|
+
'.env.example': 'PORT=8080\nDATABASE_URL=postgresql://postgres:postgres@localhost:5432/rrroutes\nVITE_API_URL=http://localhost:8080\n',
|
|
183
285
|
'README.md': `# ${pkgName}
|
|
184
286
|
|
|
185
287
|
Full stack (API + Vite web) scaffolded by manager-cli.
|
|
186
288
|
- dev: \`npm run dev\` (runs API + Vite client)
|
|
187
289
|
- build: \`npm run build\` (server to dist/server, client to dist/client)
|
|
188
290
|
- start: \`npm start\` (serves API and static client from dist)
|
|
189
|
-
- docker: \`docker build
|
|
291
|
+
- docker helper: \`npm run docker:up\` (build + run), \`npm run docker:logs\`, \`npm run docker:stop\`
|
|
292
|
+
- db helper: \`npm run db:up\` to start Postgres via docker-compose (uses DATABASE_URL)
|
|
190
293
|
`,
|
|
191
294
|
};
|
|
192
295
|
}
|
|
296
|
+
function fullstackContractTs() {
|
|
297
|
+
return `import { defineSocketEvents, finalize, resource } from '@emeryld/rrroutes-contract'
|
|
298
|
+
import { z } from 'zod'
|
|
299
|
+
|
|
300
|
+
const routes = resource('/api')
|
|
301
|
+
.sub(
|
|
302
|
+
resource('health')
|
|
303
|
+
.get({
|
|
304
|
+
outputSchema: z.object({
|
|
305
|
+
status: z.literal('ok'),
|
|
306
|
+
at: z.string(),
|
|
307
|
+
db: z.string(),
|
|
308
|
+
}),
|
|
309
|
+
description: 'Health check with DB status.',
|
|
310
|
+
})
|
|
311
|
+
.done(),
|
|
312
|
+
)
|
|
313
|
+
.done()
|
|
314
|
+
|
|
315
|
+
export const registry = finalize(routes)
|
|
316
|
+
|
|
317
|
+
const sockets = defineSocketEvents(
|
|
318
|
+
{
|
|
319
|
+
pingPayload: z.object({
|
|
320
|
+
note: z.string().default('ping'),
|
|
321
|
+
sentAt: z.string(),
|
|
322
|
+
}),
|
|
323
|
+
pongPayload: z.object({
|
|
324
|
+
ok: z.boolean(),
|
|
325
|
+
receivedAt: z.string(),
|
|
326
|
+
echo: z.string().optional(),
|
|
327
|
+
}),
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
'health:ping': {
|
|
331
|
+
message: z.object({
|
|
332
|
+
note: z.string().default('ping'),
|
|
333
|
+
}),
|
|
334
|
+
},
|
|
335
|
+
'health:pong': {
|
|
336
|
+
message: z.object({
|
|
337
|
+
ok: z.literal(true),
|
|
338
|
+
at: z.string(),
|
|
339
|
+
echo: z.string().optional(),
|
|
340
|
+
}),
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
export const socketConfig = sockets.config
|
|
346
|
+
export const socketEvents = sockets.events
|
|
347
|
+
export type AppRegistry = typeof registry
|
|
348
|
+
`;
|
|
349
|
+
}
|
|
350
|
+
function fullstackClientApi() {
|
|
351
|
+
return `import { QueryClient } from '@tanstack/react-query'
|
|
352
|
+
import { createRouteClient } from '@emeryld/rrroutes-client'
|
|
353
|
+
import { registry } from '../contract'
|
|
354
|
+
|
|
355
|
+
const baseUrl = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
|
356
|
+
export const queryClient = new QueryClient()
|
|
357
|
+
|
|
358
|
+
export const routeClient = createRouteClient({
|
|
359
|
+
baseUrl,
|
|
360
|
+
queryClient,
|
|
361
|
+
environment: import.meta.env.MODE === 'production' ? 'production' : 'development',
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
export const healthGet = routeClient.build(registry.byKey['GET /api/health'])
|
|
365
|
+
`;
|
|
366
|
+
}
|
|
367
|
+
function fullstackDockerCliScript(pkgName) {
|
|
368
|
+
return `#!/usr/bin/env tsx
|
|
369
|
+
import { readFile } from 'node:fs/promises'
|
|
370
|
+
import path from 'node:path'
|
|
371
|
+
import { fileURLToPath } from 'node:url'
|
|
372
|
+
import { Docker } from 'docker-cli-js'
|
|
373
|
+
|
|
374
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
375
|
+
const __dirname = path.dirname(__filename)
|
|
376
|
+
const pkgRaw = await readFile(path.join(__dirname, '..', 'package.json'), 'utf8')
|
|
377
|
+
const pkg = JSON.parse(pkgRaw) as { name?: string }
|
|
378
|
+
const image = \`\${pkg.name ?? '${pkgName}'}:latest\`
|
|
379
|
+
const container =
|
|
380
|
+
(pkg.name ?? '${pkgName}').replace(/[^a-z0-9]/gi, '-').replace(/^-+|-+$/g, '') || 'rrr-fullstack'
|
|
381
|
+
const port = process.env.PORT ?? '8080'
|
|
382
|
+
const docker = new Docker({ spawnOptions: { stdio: 'inherit' } })
|
|
383
|
+
|
|
384
|
+
async function main() {
|
|
385
|
+
const [command = 'help'] = process.argv.slice(2)
|
|
386
|
+
if (command === 'help') return printHelp()
|
|
387
|
+
if (command === 'build') return docker.command(\`build -t \${image} .\`)
|
|
388
|
+
if (command === 'up') {
|
|
389
|
+
await docker.command(\`build -t \${image} .\`)
|
|
390
|
+
return docker.command(\`run -d --rm -p \${port}:\${port} --name \${container} \${image}\`)
|
|
391
|
+
}
|
|
392
|
+
if (command === 'run') {
|
|
393
|
+
return docker.command(\`run -d --rm -p \${port}:\${port} --name \${container} \${image}\`)
|
|
394
|
+
}
|
|
395
|
+
if (command === 'logs') return docker.command(\`logs -f \${container}\`)
|
|
396
|
+
if (command === 'stop') return docker.command(\`stop \${container}\`)
|
|
397
|
+
if (command === 'clean') {
|
|
398
|
+
try {
|
|
399
|
+
await docker.command(\`rm -f \${container}\`)
|
|
400
|
+
} catch (error) {
|
|
401
|
+
console.warn(String(error))
|
|
402
|
+
}
|
|
403
|
+
return
|
|
404
|
+
}
|
|
405
|
+
return printHelp()
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function printHelp() {
|
|
409
|
+
console.log(
|
|
410
|
+
[
|
|
411
|
+
'Docker helper commands:',
|
|
412
|
+
' build -> docker build -t ${pkgName}:latest .',
|
|
413
|
+
' up -> build then run in detached mode',
|
|
414
|
+
' run -> run existing image detached',
|
|
415
|
+
' logs -> docker logs -f <container>',
|
|
416
|
+
' stop -> docker stop <container>',
|
|
417
|
+
' clean -> docker rm -f <container>',
|
|
418
|
+
].join('\\n'),
|
|
419
|
+
)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
main().catch((err) => {
|
|
423
|
+
console.error(err)
|
|
424
|
+
process.exit(1)
|
|
425
|
+
})
|
|
426
|
+
`;
|
|
427
|
+
}
|
|
193
428
|
export const fullstackVariant = {
|
|
194
429
|
id: 'rrr-fullstack',
|
|
195
430
|
label: 'full stack service (api + web)',
|