@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.
@@ -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
@@ -95,6 +95,8 @@ export function buildPackageSelectionMenu(packages, onStepComplete) {
95
95
  onStepComplete?.(step);
96
96
  },
97
97
  }));
98
+ if (ordered.length === 0)
99
+ return entries;
98
100
  entries.push({
99
101
  name: 'All packages',
100
102
  emoji: globalEmoji,
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.slice(0, 2))
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: buildPackageSelectionMenu(packages, (step) => {
98
- lastStep = step;
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emeryld/manager",
3
- "version": "0.2.4",
3
+ "version": "0.3.1",
4
4
  "description": "Interactive manager for pnpm monorepos (update/test/build/publish).",
5
5
  "license": "MIT",
6
6
  "type": "module",