@emeryld/manager 0.5.1 → 0.6.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/README.md +55 -19
- package/dist/create-package/index.js +43 -1
- package/dist/create-package/variants/client/client_expo_rn.js +530 -0
- package/dist/create-package/variants/client/client_vite_r.js +583 -0
- package/dist/create-package/variants/client/shared.js +56 -0
- package/dist/create-package/variants/client.js +1 -122
- package/dist/create-package/variants/docker.js +21 -142
- package/dist/create-package/variants/empty.js +1 -0
- package/dist/create-package/variants/fullstack.js +262 -10
- package/dist/docker.js +128 -0
- package/dist/menu.js +44 -10
- package/dist/packages.js +12 -1
- package/package.json +1 -1
|
@@ -1,122 +1 @@
|
|
|
1
|
-
|
|
2
|
-
const CLIENT_SCRIPTS = [
|
|
3
|
-
'dev',
|
|
4
|
-
'build',
|
|
5
|
-
'typecheck',
|
|
6
|
-
'lint',
|
|
7
|
-
'lint:fix',
|
|
8
|
-
'format',
|
|
9
|
-
'format:check',
|
|
10
|
-
'clean',
|
|
11
|
-
'test',
|
|
12
|
-
];
|
|
13
|
-
export function clientIndexTs(contractImport) {
|
|
14
|
-
const contractImportLine = contractImport
|
|
15
|
-
? `import { registry } from '${contractImport}'`
|
|
16
|
-
: "// TODO: import { registry } from '@your-scope/contract'";
|
|
17
|
-
const routeExports = contractImport
|
|
18
|
-
? `export const healthGet = routeClient.build(registry.byKey['GET /api/health'])
|
|
19
|
-
export const healthPost = routeClient.build(registry.byKey['POST /api/health'])`
|
|
20
|
-
: `// TODO: add route builders after wiring your contract registry
|
|
21
|
-
// export const healthGet = routeClient.build(registry.byKey['GET /api/health'])
|
|
22
|
-
// export const healthPost = routeClient.build(registry.byKey['POST /api/health'])`;
|
|
23
|
-
return `import { QueryClient } from '@tanstack/react-query'
|
|
24
|
-
import { createRouteClient } from '@emeryld/rrroutes-client'
|
|
25
|
-
${contractImportLine}
|
|
26
|
-
|
|
27
|
-
const baseUrl = process.env.RRR_API_URL ?? 'http://localhost:4000'
|
|
28
|
-
export const queryClient = new QueryClient()
|
|
29
|
-
|
|
30
|
-
export const routeClient = createRouteClient({
|
|
31
|
-
baseUrl,
|
|
32
|
-
queryClient,
|
|
33
|
-
environment: process.env.NODE_ENV === 'production' ? 'production' : 'development',
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
${routeExports}
|
|
37
|
-
`;
|
|
38
|
-
}
|
|
39
|
-
export function clientPackageJson(name, contractName, options) {
|
|
40
|
-
const dependencies = {
|
|
41
|
-
'@emeryld/rrroutes-client': '^2.5.3',
|
|
42
|
-
'@tanstack/react-query': '^5.90.12',
|
|
43
|
-
'socket.io-client': '^4.8.3',
|
|
44
|
-
};
|
|
45
|
-
if (contractName)
|
|
46
|
-
dependencies[contractName] = 'workspace:*';
|
|
47
|
-
return basePackageJson({
|
|
48
|
-
name,
|
|
49
|
-
scripts: baseScripts('tsx watch src/index.ts', undefined, {
|
|
50
|
-
includePrepare: options?.includePrepare,
|
|
51
|
-
}),
|
|
52
|
-
dependencies,
|
|
53
|
-
devDependencies: {
|
|
54
|
-
...BASE_LINT_DEV_DEPENDENCIES,
|
|
55
|
-
'@types/node': '^24.10.2',
|
|
56
|
-
},
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
async function clientFiles(pkgName, contractImport, targetDir) {
|
|
60
|
-
const includePrepare = isWorkspaceRoot(targetDir);
|
|
61
|
-
const tsconfig = await packageTsConfig(targetDir, {
|
|
62
|
-
include: ['src/**/*.ts', 'src/**/*.tsx'],
|
|
63
|
-
lib: ['ES2020', 'DOM'],
|
|
64
|
-
types: ['react', 'react-native'],
|
|
65
|
-
jsx: 'react-jsx',
|
|
66
|
-
});
|
|
67
|
-
return {
|
|
68
|
-
'package.json': clientPackageJson(pkgName, contractImport, { includePrepare }),
|
|
69
|
-
'tsconfig.json': tsconfig,
|
|
70
|
-
...basePackageFiles(),
|
|
71
|
-
'src/index.ts': clientIndexTs(contractImport),
|
|
72
|
-
'README.md': buildReadme({
|
|
73
|
-
name: pkgName,
|
|
74
|
-
description: 'Starter RRRoutes client scaffold.',
|
|
75
|
-
scripts: CLIENT_SCRIPTS,
|
|
76
|
-
sections: [
|
|
77
|
-
{
|
|
78
|
-
title: 'Getting Started',
|
|
79
|
-
lines: [
|
|
80
|
-
'- Install deps: `npm install` (or `pnpm install`)',
|
|
81
|
-
'- Start dev mode: `npm run dev`',
|
|
82
|
-
'- Build output: `npm run build`',
|
|
83
|
-
],
|
|
84
|
-
},
|
|
85
|
-
{
|
|
86
|
-
title: 'Usage',
|
|
87
|
-
lines: [
|
|
88
|
-
contractImport
|
|
89
|
-
? `- Contract wired to ${contractImport}; adjust the import in \`src/index.ts\` if needed.`
|
|
90
|
-
: '- Add your contract import to `src/index.ts` and wire the registry when ready.',
|
|
91
|
-
'- Use the exported `queryClient` and built route clients from `src/index.ts`.',
|
|
92
|
-
],
|
|
93
|
-
},
|
|
94
|
-
{
|
|
95
|
-
title: 'Environment',
|
|
96
|
-
lines: ['- `RRR_API_URL` (optional) sets the API base URL (default http://localhost:4000).'],
|
|
97
|
-
},
|
|
98
|
-
],
|
|
99
|
-
}),
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
export const clientVariant = {
|
|
103
|
-
id: 'rrr-client',
|
|
104
|
-
label: 'rrr client',
|
|
105
|
-
defaultDir: 'packages/rrr-client',
|
|
106
|
-
summary: 'React Query-ready RRRoutes client bound to a shared contract import.',
|
|
107
|
-
keyFiles: ['src/index.ts', 'README.md'],
|
|
108
|
-
scripts: CLIENT_SCRIPTS,
|
|
109
|
-
notes: [
|
|
110
|
-
'Pick a contract from discovered workspace packages (or skip) during scaffolding.',
|
|
111
|
-
'Set the contract import via --contract or by editing src/index.ts.',
|
|
112
|
-
'Exports query client + typed route builders to plug into React apps.',
|
|
113
|
-
],
|
|
114
|
-
async scaffold(ctx) {
|
|
115
|
-
const contractImport = ctx.contractName;
|
|
116
|
-
const files = await clientFiles(ctx.pkgName, contractImport, ctx.targetDir);
|
|
117
|
-
for (const [relative, contents] of Object.entries(files)) {
|
|
118
|
-
// eslint-disable-next-line no-await-in-loop
|
|
119
|
-
await writeFileIfMissing(ctx.targetDir, relative, contents);
|
|
120
|
-
}
|
|
121
|
-
},
|
|
122
|
-
};
|
|
1
|
+
export { clientVariant, CLIENT_KIND_OPTIONS, DEFAULT_CLIENT_KIND, normalizeClientKind, } from './client/shared.js';
|
|
@@ -18,19 +18,29 @@ const DOCKER_SCRIPTS = [
|
|
|
18
18
|
'docker:clean',
|
|
19
19
|
'docker:reset',
|
|
20
20
|
];
|
|
21
|
+
function sanitizeContainerName(name) {
|
|
22
|
+
const normalized = name
|
|
23
|
+
.toLowerCase()
|
|
24
|
+
.replace(/[^a-z0-9]/gi, '-')
|
|
25
|
+
.replace(/-+/g, '-')
|
|
26
|
+
.replace(/^-+|-+$/g, '') || 'rrr-service';
|
|
27
|
+
return normalized;
|
|
28
|
+
}
|
|
21
29
|
function dockerPackageJson(name, options) {
|
|
30
|
+
const image = `${name}:latest`;
|
|
31
|
+
const container = sanitizeContainerName(name);
|
|
32
|
+
const portVar = '${PORT:-3000}';
|
|
22
33
|
return basePackageJson({
|
|
23
34
|
name,
|
|
24
35
|
scripts: baseScripts('tsx watch src/index.ts', {
|
|
25
36
|
start: 'node dist/index.js',
|
|
26
|
-
'docker:
|
|
27
|
-
'docker:
|
|
28
|
-
'docker:
|
|
29
|
-
'docker:
|
|
30
|
-
'docker:
|
|
31
|
-
'docker:
|
|
32
|
-
'docker:
|
|
33
|
-
'docker:reset': 'npm run docker:cli -- reset',
|
|
37
|
+
'docker:build': `docker build -t ${image} .`,
|
|
38
|
+
'docker:up': `npm run docker:build && PORT=${portVar} docker run -d --rm -p ${portVar}:${portVar} --name ${container} ${image}`,
|
|
39
|
+
'docker:dev': `npm run docker:build && PORT=${portVar} docker run -d --rm -p ${portVar}:${portVar} --name ${container} ${image} && docker logs -f ${container}`,
|
|
40
|
+
'docker:logs': `docker logs -f ${container}`,
|
|
41
|
+
'docker:stop': `docker stop ${container}`,
|
|
42
|
+
'docker:clean': `docker stop ${container} || true; docker rm -f ${container} || true`,
|
|
43
|
+
'docker:reset': `npm run docker:clean && docker rmi -f ${image} || true`,
|
|
34
44
|
}, { includePrepare: options?.includePrepare }),
|
|
35
45
|
dependencies: {
|
|
36
46
|
cors: '^2.8.5',
|
|
@@ -41,7 +51,6 @@ function dockerPackageJson(name, options) {
|
|
|
41
51
|
'@types/cors': '^2.8.5',
|
|
42
52
|
'@types/express': '^5.0.6',
|
|
43
53
|
'@types/node': '^24.10.2',
|
|
44
|
-
'docker-cli-js': '^2.10.0',
|
|
45
54
|
},
|
|
46
55
|
});
|
|
47
56
|
}
|
|
@@ -105,7 +114,6 @@ async function dockerFiles(pkgName, targetDir) {
|
|
|
105
114
|
'package.json': dockerPackageJson(pkgName, { includePrepare }),
|
|
106
115
|
'tsconfig.json': tsconfig,
|
|
107
116
|
'src/index.ts': dockerIndexTs(),
|
|
108
|
-
'scripts/docker.ts': dockerCliScript(pkgName),
|
|
109
117
|
'.dockerignore': DOCKER_DOCKERIGNORE,
|
|
110
118
|
...basePackageFiles(),
|
|
111
119
|
Dockerfile: dockerDockerfile(),
|
|
@@ -143,145 +151,16 @@ async function dockerFiles(pkgName, targetDir) {
|
|
|
143
151
|
}),
|
|
144
152
|
};
|
|
145
153
|
}
|
|
146
|
-
function dockerCliScript(pkgName) {
|
|
147
|
-
return `#!/usr/bin/env tsx
|
|
148
|
-
import { readFile } from 'node:fs/promises'
|
|
149
|
-
import path from 'node:path'
|
|
150
|
-
import { fileURLToPath } from 'node:url'
|
|
151
|
-
import { Docker } from 'docker-cli-js'
|
|
152
|
-
import { runHelperCli } from '@emeryld/manager/dist/helper-cli.js'
|
|
153
|
-
|
|
154
|
-
const __filename = fileURLToPath(import.meta.url)
|
|
155
|
-
const __dirname = path.dirname(__filename)
|
|
156
|
-
const pkgRoot = path.join(__dirname, '..')
|
|
157
|
-
const pkgRaw = await readFile(path.join(pkgRoot, 'package.json'), 'utf8')
|
|
158
|
-
const pkg = JSON.parse(pkgRaw) as { name?: string }
|
|
159
|
-
const image = \`\${pkg.name ?? '${pkgName}'}:latest\`
|
|
160
|
-
const container =
|
|
161
|
-
(pkg.name ?? '${pkgName}')
|
|
162
|
-
.replace(/[^a-z0-9]/gi, '-')
|
|
163
|
-
.replace(/^-+|-+$/g, '') || 'rrr-service'
|
|
164
|
-
const port = process.env.PORT ?? '3000'
|
|
165
|
-
const docker = new Docker({ spawnOptions: { stdio: 'inherit' } })
|
|
166
|
-
|
|
167
|
-
async function safe(run: () => Promise<unknown>) {
|
|
168
|
-
try {
|
|
169
|
-
await run()
|
|
170
|
-
} catch (error: unknown) {
|
|
171
|
-
console.warn(error instanceof Error ? error.message : String(error))
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function printHelp() {
|
|
176
|
-
console.log(
|
|
177
|
-
[
|
|
178
|
-
'Docker helper commands:',
|
|
179
|
-
' build -> docker build -t ${pkgName}:latest .',
|
|
180
|
-
' up -> build then run in detached mode',
|
|
181
|
-
' dev -> build, run, and tail logs',
|
|
182
|
-
' run -> run existing image detached',
|
|
183
|
-
' logs -> docker logs -f <container>',
|
|
184
|
-
' stop -> docker stop <container>',
|
|
185
|
-
' clean -> docker stop/rm <container>',
|
|
186
|
-
' reset -> clean container and remove image',
|
|
187
|
-
].join('\\n'),
|
|
188
|
-
)
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
await runHelperCli({
|
|
192
|
-
title: 'Docker helper',
|
|
193
|
-
argv: process.argv.slice(2),
|
|
194
|
-
scripts: [
|
|
195
|
-
{
|
|
196
|
-
name: 'build',
|
|
197
|
-
emoji: '🔨',
|
|
198
|
-
description: 'docker build -t image .',
|
|
199
|
-
handler: () => docker.command(\`build -t \${image} .\`),
|
|
200
|
-
},
|
|
201
|
-
{
|
|
202
|
-
name: 'up',
|
|
203
|
-
emoji: '⬆️',
|
|
204
|
-
description: 'Build + run detached',
|
|
205
|
-
handler: async () => {
|
|
206
|
-
await docker.command(\`build -t \${image} .\`)
|
|
207
|
-
await docker.command(\`run -d --rm -p \${port}:\${port} --name \${container} \${image}\`)
|
|
208
|
-
},
|
|
209
|
-
},
|
|
210
|
-
{
|
|
211
|
-
name: 'dev',
|
|
212
|
-
emoji: '🛠️',
|
|
213
|
-
description: 'Build, run, tail logs',
|
|
214
|
-
handler: async () => {
|
|
215
|
-
await docker.command(\`build -t \${image} .\`)
|
|
216
|
-
await docker.command(\`run -d --rm -p \${port}:\${port} --name \${container} \${image}\`)
|
|
217
|
-
await docker.command(\`logs -f \${container}\`)
|
|
218
|
-
},
|
|
219
|
-
},
|
|
220
|
-
{
|
|
221
|
-
name: 'run',
|
|
222
|
-
emoji: '🏃',
|
|
223
|
-
description: 'Run existing image detached',
|
|
224
|
-
handler: () =>
|
|
225
|
-
docker.command(\`run -d --rm -p \${port}:\${port} --name \${container} \${image}\`),
|
|
226
|
-
},
|
|
227
|
-
{
|
|
228
|
-
name: 'logs',
|
|
229
|
-
emoji: '📜',
|
|
230
|
-
description: 'Tail docker logs',
|
|
231
|
-
handler: () => docker.command(\`logs -f \${container}\`),
|
|
232
|
-
},
|
|
233
|
-
{
|
|
234
|
-
name: 'stop',
|
|
235
|
-
emoji: '🛑',
|
|
236
|
-
description: 'Stop container',
|
|
237
|
-
handler: () => docker.command(\`stop \${container}\`),
|
|
238
|
-
},
|
|
239
|
-
{
|
|
240
|
-
name: 'clean',
|
|
241
|
-
emoji: '🧹',
|
|
242
|
-
description: 'Stop + remove container',
|
|
243
|
-
handler: async () => {
|
|
244
|
-
await safe(() => docker.command(\`stop \${container}\`))
|
|
245
|
-
await safe(() => docker.command(\`rm -f \${container}\`))
|
|
246
|
-
},
|
|
247
|
-
},
|
|
248
|
-
{
|
|
249
|
-
name: 'reset',
|
|
250
|
-
emoji: '♻️',
|
|
251
|
-
description: 'Clean container and remove image',
|
|
252
|
-
handler: async () => {
|
|
253
|
-
await safe(() => docker.command(\`stop \${container}\`))
|
|
254
|
-
await safe(() => docker.command(\`rm -f \${container}\`))
|
|
255
|
-
await safe(() => docker.command(\`rmi -f \${image}\`))
|
|
256
|
-
},
|
|
257
|
-
},
|
|
258
|
-
{
|
|
259
|
-
name: 'help',
|
|
260
|
-
emoji: 'ℹ️',
|
|
261
|
-
description: 'Print commands',
|
|
262
|
-
handler: () => {
|
|
263
|
-
printHelp()
|
|
264
|
-
},
|
|
265
|
-
},
|
|
266
|
-
],
|
|
267
|
-
})
|
|
268
|
-
`;
|
|
269
|
-
}
|
|
270
154
|
export const dockerVariant = {
|
|
271
155
|
id: 'rrr-docker',
|
|
272
156
|
label: 'dockerized service',
|
|
273
157
|
defaultDir: 'packages/rrr-docker',
|
|
274
|
-
summary: 'Express service plus Dockerfile and
|
|
275
|
-
keyFiles: [
|
|
276
|
-
'src/index.ts',
|
|
277
|
-
'scripts/docker.ts',
|
|
278
|
-
'Dockerfile',
|
|
279
|
-
'README.md',
|
|
280
|
-
],
|
|
158
|
+
summary: 'Express service plus Dockerfile and docker scripts for local runs.',
|
|
159
|
+
keyFiles: ['src/index.ts', 'Dockerfile', 'README.md'],
|
|
281
160
|
scripts: DOCKER_SCRIPTS,
|
|
282
161
|
notes: [
|
|
283
162
|
'Use docker:dev or docker:up to build and run the container quickly.',
|
|
284
|
-
'
|
|
163
|
+
'Manager CLI surfaces Docker helpers automatically when a Dockerfile is present.',
|
|
285
164
|
],
|
|
286
165
|
async scaffold(ctx) {
|
|
287
166
|
const files = await dockerFiles(ctx.pkgName, ctx.targetDir);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { access, readFile, writeFile } from 'node:fs/promises';
|
|
1
2
|
import path from 'node:path';
|
|
2
|
-
import { BASE_LINT_DEV_DEPENDENCIES, basePackageFiles, basePackageJson, writeFileIfMissing, } from '../shared.js';
|
|
3
|
-
import { clientVariant } from './client.js';
|
|
3
|
+
import { BASE_LINT_DEV_DEPENDENCIES, basePackageFiles, basePackageJson, workspaceRoot, writeFileIfMissing, } from '../shared.js';
|
|
4
|
+
import { clientVariant, DEFAULT_CLIENT_KIND } from './client.js';
|
|
4
5
|
import { serverVariant } from './server.js';
|
|
5
6
|
import { dockerVariant } from './docker.js';
|
|
6
7
|
import { contractVariant } from './contract.js';
|
|
@@ -46,8 +47,230 @@ function deriveDirs(rootDir, baseName) {
|
|
|
46
47
|
function toPosixPath(value) {
|
|
47
48
|
return value.split(path.sep).join('/');
|
|
48
49
|
}
|
|
49
|
-
function
|
|
50
|
+
async function pathExists(target) {
|
|
51
|
+
try {
|
|
52
|
+
await access(target);
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function normalizeWorkspaceGlob(value) {
|
|
60
|
+
const unquoted = value.replace(/^['"]|['"]$/g, '').trim();
|
|
61
|
+
const normalized = toPosixPath(unquoted || '');
|
|
62
|
+
return normalized.replace(/^\.\//, '');
|
|
63
|
+
}
|
|
64
|
+
function globCoversCandidate(pattern, candidate) {
|
|
65
|
+
const normalizedPattern = normalizeWorkspaceGlob(pattern);
|
|
66
|
+
const normalizedCandidate = normalizeWorkspaceGlob(candidate);
|
|
67
|
+
if (!normalizedPattern || !normalizedCandidate)
|
|
68
|
+
return false;
|
|
69
|
+
if (normalizedPattern === normalizedCandidate)
|
|
70
|
+
return true;
|
|
71
|
+
if (normalizedPattern === '*' || normalizedPattern === '**')
|
|
72
|
+
return true;
|
|
73
|
+
if (normalizedPattern.endsWith('/**')) {
|
|
74
|
+
return normalizedCandidate.startsWith(normalizedPattern.slice(0, -3));
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
function patternsContain(patterns, candidate) {
|
|
79
|
+
return patterns.some((pattern) => globCoversCandidate(pattern, candidate));
|
|
80
|
+
}
|
|
81
|
+
function parsePnpmWorkspacePackages(raw) {
|
|
82
|
+
const packages = [];
|
|
83
|
+
const lines = raw.split(/\r?\n/);
|
|
84
|
+
const packagesIndex = lines.findIndex((line) => line.trim().startsWith('packages:'));
|
|
85
|
+
if (packagesIndex === -1)
|
|
86
|
+
return packages;
|
|
87
|
+
const baseIndent = lines[packagesIndex]?.match(/^(\s*)/)?.[1] ?? '';
|
|
88
|
+
for (let i = packagesIndex + 1; i < lines.length; i++) {
|
|
89
|
+
const line = lines[i];
|
|
90
|
+
const trimmed = line.trim();
|
|
91
|
+
if (!trimmed)
|
|
92
|
+
continue;
|
|
93
|
+
const indent = line.match(/^(\s*)/)?.[1] ?? '';
|
|
94
|
+
if (indent.length <= baseIndent.length && !trimmed.startsWith('-'))
|
|
95
|
+
break;
|
|
96
|
+
if (!trimmed.startsWith('-')) {
|
|
97
|
+
if (indent.length <= baseIndent.length)
|
|
98
|
+
break;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const value = trimmed.slice(1).trim().replace(/^['"]|['"]$/g, '');
|
|
102
|
+
if (value)
|
|
103
|
+
packages.push(value);
|
|
104
|
+
}
|
|
105
|
+
return packages;
|
|
106
|
+
}
|
|
107
|
+
function insertIntoPnpmWorkspace(raw, pattern) {
|
|
108
|
+
const normalizedPattern = normalizeWorkspaceGlob(pattern);
|
|
109
|
+
if (!normalizedPattern)
|
|
110
|
+
return { added: false, content: raw };
|
|
111
|
+
const existing = parsePnpmWorkspacePackages(raw);
|
|
112
|
+
if (patternsContain(existing, normalizedPattern)) {
|
|
113
|
+
return { added: false, content: raw };
|
|
114
|
+
}
|
|
115
|
+
const lines = raw.split(/\r?\n/);
|
|
116
|
+
const packagesIndex = lines.findIndex((line) => line.trim().startsWith('packages:'));
|
|
117
|
+
const entryLine = packagesIndex === -1
|
|
118
|
+
? ` - '${normalizedPattern}'`
|
|
119
|
+
: `${lines[packagesIndex]?.match(/^(\s*)/)?.[1] ?? ''} - '${normalizedPattern}'`;
|
|
120
|
+
if (packagesIndex === -1) {
|
|
121
|
+
const prefix = raw.trimEnd() ? `${raw.trimEnd()}\n` : '';
|
|
122
|
+
const content = `${prefix}packages:\n${entryLine}\n`;
|
|
123
|
+
return { added: true, content };
|
|
124
|
+
}
|
|
125
|
+
let insertAt = packagesIndex + 1;
|
|
126
|
+
const baseIndent = lines[packagesIndex]?.match(/^(\s*)/)?.[1] ?? '';
|
|
127
|
+
for (let i = packagesIndex + 1; i < lines.length; i++) {
|
|
128
|
+
const line = lines[i];
|
|
129
|
+
const trimmed = line.trim();
|
|
130
|
+
const indent = line.match(/^(\s*)/)?.[1] ?? '';
|
|
131
|
+
if (!trimmed) {
|
|
132
|
+
insertAt = i + 1;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (indent.length <= baseIndent.length && !trimmed.startsWith('-')) {
|
|
136
|
+
insertAt = i;
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
if (trimmed.startsWith('-')) {
|
|
140
|
+
insertAt = i + 1;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (indent.length <= baseIndent.length) {
|
|
144
|
+
insertAt = i;
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
lines.splice(insertAt, 0, entryLine);
|
|
149
|
+
const content = lines.join('\n').replace(/\n+$/, '\n');
|
|
150
|
+
return { added: true, content };
|
|
151
|
+
}
|
|
152
|
+
async function readPackageJsonWorkspaces(configPath) {
|
|
153
|
+
const raw = await readFile(configPath, 'utf8');
|
|
154
|
+
const pkg = JSON.parse(raw);
|
|
155
|
+
const workspaces = pkg.workspaces;
|
|
156
|
+
if (typeof workspaces === 'string')
|
|
157
|
+
return [workspaces];
|
|
158
|
+
if (Array.isArray(workspaces))
|
|
159
|
+
return workspaces.map(normalizeWorkspaceGlob).filter(Boolean);
|
|
160
|
+
if (workspaces && typeof workspaces === 'object' && 'packages' in workspaces) {
|
|
161
|
+
const packages = workspaces.packages;
|
|
162
|
+
if (typeof packages === 'string')
|
|
163
|
+
return [packages];
|
|
164
|
+
if (Array.isArray(packages))
|
|
165
|
+
return packages.map(normalizeWorkspaceGlob).filter(Boolean);
|
|
166
|
+
}
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
async function readPnpmWorkspace(configPath) {
|
|
170
|
+
const raw = await readFile(configPath, 'utf8');
|
|
171
|
+
return parsePnpmWorkspacePackages(raw).map(normalizeWorkspaceGlob).filter(Boolean);
|
|
172
|
+
}
|
|
173
|
+
async function findWorkspaceRoot(startDir) {
|
|
174
|
+
let current = path.resolve(startDir);
|
|
175
|
+
// eslint-disable-next-line no-constant-condition
|
|
176
|
+
while (true) {
|
|
177
|
+
const pnpmPath = path.join(current, 'pnpm-workspace.yaml');
|
|
178
|
+
if (await pathExists(pnpmPath)) {
|
|
179
|
+
const patterns = await readPnpmWorkspace(pnpmPath);
|
|
180
|
+
return { rootDir: current, configPath: pnpmPath, type: 'pnpm-workspace', patterns };
|
|
181
|
+
}
|
|
182
|
+
const pkgPath = path.join(current, 'package.json');
|
|
183
|
+
if (await pathExists(pkgPath)) {
|
|
184
|
+
const patterns = await readPackageJsonWorkspaces(pkgPath);
|
|
185
|
+
if (patterns) {
|
|
186
|
+
return { rootDir: current, configPath: pkgPath, type: 'package-json', patterns };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const parent = path.dirname(current);
|
|
190
|
+
if (parent === current)
|
|
191
|
+
break;
|
|
192
|
+
current = parent;
|
|
193
|
+
}
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
async function updatePnpmWorkspaceFile(configPath, pattern) {
|
|
197
|
+
const raw = await readFile(configPath, 'utf8');
|
|
198
|
+
const { added, content } = insertIntoPnpmWorkspace(raw, pattern);
|
|
199
|
+
if (!added)
|
|
200
|
+
return false;
|
|
201
|
+
await writeFile(configPath, content, 'utf8');
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
async function updatePackageJsonWorkspaces(configPath, pattern) {
|
|
205
|
+
const raw = await readFile(configPath, 'utf8');
|
|
206
|
+
const pkg = JSON.parse(raw);
|
|
207
|
+
const normalizedPattern = normalizeWorkspaceGlob(pattern);
|
|
208
|
+
if (!normalizedPattern)
|
|
209
|
+
return false;
|
|
210
|
+
if (typeof pkg.workspaces === 'string') {
|
|
211
|
+
if (globCoversCandidate(pkg.workspaces, normalizedPattern))
|
|
212
|
+
return false;
|
|
213
|
+
pkg.workspaces = [pkg.workspaces, normalizedPattern];
|
|
214
|
+
}
|
|
215
|
+
else if (Array.isArray(pkg.workspaces)) {
|
|
216
|
+
if (patternsContain(pkg.workspaces, normalizedPattern))
|
|
217
|
+
return false;
|
|
218
|
+
pkg.workspaces.push(normalizedPattern);
|
|
219
|
+
}
|
|
220
|
+
else if (pkg.workspaces && typeof pkg.workspaces === 'object') {
|
|
221
|
+
const packages = pkg.workspaces.packages;
|
|
222
|
+
if (Array.isArray(packages)) {
|
|
223
|
+
if (patternsContain(packages, normalizedPattern))
|
|
224
|
+
return false;
|
|
225
|
+
packages.push(normalizedPattern);
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
pkg.workspaces.packages = [normalizedPattern];
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
pkg.workspaces = [normalizedPattern];
|
|
233
|
+
}
|
|
234
|
+
const next = `${JSON.stringify(pkg, null, 2)}\n`;
|
|
235
|
+
await writeFile(configPath, next, 'utf8');
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
async function ensureWorkspacePattern(info, pattern) {
|
|
239
|
+
const normalizedPattern = normalizeWorkspaceGlob(pattern);
|
|
240
|
+
if (!normalizedPattern)
|
|
241
|
+
return false;
|
|
242
|
+
if (patternsContain(info.patterns, normalizedPattern))
|
|
243
|
+
return false;
|
|
244
|
+
const updated = info.type === 'pnpm-workspace'
|
|
245
|
+
? await updatePnpmWorkspaceFile(info.configPath, normalizedPattern)
|
|
246
|
+
: await updatePackageJsonWorkspaces(info.configPath, normalizedPattern);
|
|
247
|
+
if (updated) {
|
|
248
|
+
info.patterns.push(normalizedPattern);
|
|
249
|
+
const rel = path.relative(workspaceRoot, info.configPath) || path.basename(info.configPath);
|
|
250
|
+
console.log(` updated ${rel} (added workspace path ${normalizedPattern})`);
|
|
251
|
+
}
|
|
252
|
+
return updated;
|
|
253
|
+
}
|
|
254
|
+
function workspacePatternForPackagesRoot(workspaceRootDir, packagesRoot) {
|
|
255
|
+
const relative = toPosixPath(path.relative(workspaceRootDir, path.join(packagesRoot, '*')));
|
|
256
|
+
const normalized = relative || './packages/*';
|
|
257
|
+
return normalized.startsWith('./') ? normalized.slice(2) : normalized;
|
|
258
|
+
}
|
|
259
|
+
function workspacePatternForStackRoot(workspaceRootDir, stackRoot) {
|
|
260
|
+
const relative = toPosixPath(path.relative(workspaceRootDir, stackRoot));
|
|
261
|
+
const normalized = relative || '';
|
|
262
|
+
if (!normalized || normalized === '.')
|
|
263
|
+
return undefined;
|
|
264
|
+
return normalized.startsWith('./') ? normalized.slice(2) : normalized;
|
|
265
|
+
}
|
|
266
|
+
function rootPackageJson(baseName, options) {
|
|
50
267
|
const dockerPackageDir = `packages/${baseName}-docker`;
|
|
268
|
+
const includeWorkspaces = options?.includeWorkspaces ?? true;
|
|
269
|
+
const workspacePatterns = includeWorkspaces && options?.workspacePatterns?.length
|
|
270
|
+
? options.workspacePatterns
|
|
271
|
+
: includeWorkspaces
|
|
272
|
+
? ['packages/*']
|
|
273
|
+
: undefined;
|
|
51
274
|
return basePackageJson({
|
|
52
275
|
name: `${baseName}-stack`,
|
|
53
276
|
private: true,
|
|
@@ -73,12 +296,16 @@ function rootPackageJson(baseName) {
|
|
|
73
296
|
},
|
|
74
297
|
devDependencies: { ...BASE_LINT_DEV_DEPENDENCIES },
|
|
75
298
|
extraFields: {
|
|
76
|
-
workspaces:
|
|
299
|
+
...(workspacePatterns ? { workspaces: workspacePatterns } : {}),
|
|
77
300
|
},
|
|
78
301
|
});
|
|
79
302
|
}
|
|
80
|
-
function rootPnpmWorkspace() {
|
|
81
|
-
|
|
303
|
+
function rootPnpmWorkspace(patterns) {
|
|
304
|
+
const lines = ['packages:'];
|
|
305
|
+
patterns.forEach((pattern) => {
|
|
306
|
+
lines.push(` - '${pattern}'`);
|
|
307
|
+
});
|
|
308
|
+
return `${lines.join('\n')}\n`;
|
|
82
309
|
}
|
|
83
310
|
function rootTsconfigBase(baseName, names) {
|
|
84
311
|
const paths = {
|
|
@@ -142,10 +369,17 @@ volumes:
|
|
|
142
369
|
db-data:
|
|
143
370
|
`;
|
|
144
371
|
}
|
|
145
|
-
async function scaffoldRootFiles(baseDir, baseName, names, dirs) {
|
|
372
|
+
async function scaffoldRootFiles(baseDir, baseName, names, dirs, options) {
|
|
373
|
+
const includeWorkspaceFiles = options?.includeWorkspaceFiles ?? true;
|
|
374
|
+
const workspacePatterns = options?.workspacePatterns?.filter(Boolean) ?? ['packages/*'];
|
|
146
375
|
const files = {
|
|
147
|
-
'package.json': rootPackageJson(baseName
|
|
148
|
-
|
|
376
|
+
'package.json': rootPackageJson(baseName, {
|
|
377
|
+
includeWorkspaces: includeWorkspaceFiles,
|
|
378
|
+
workspacePatterns,
|
|
379
|
+
}),
|
|
380
|
+
...(includeWorkspaceFiles
|
|
381
|
+
? { 'pnpm-workspace.yaml': rootPnpmWorkspace(workspacePatterns) }
|
|
382
|
+
: {}),
|
|
149
383
|
'docker-compose.yml': stackComposeYaml(),
|
|
150
384
|
'tsconfig.base.json': rootTsconfigBase(baseName, names),
|
|
151
385
|
'tsconfig.json': rootSolutionTsconfig(dirs),
|
|
@@ -174,13 +408,30 @@ export const fullstackVariant = {
|
|
|
174
408
|
notes: [
|
|
175
409
|
'Generates four packages and a workspace root; great starting point when you need the whole stack.',
|
|
176
410
|
'Use --name to control the workspace prefix (default is the target folder name).',
|
|
411
|
+
'If you scaffold inside an existing workspace, the root workspace config is updated instead of nesting a new one.',
|
|
177
412
|
],
|
|
178
413
|
async scaffold(ctx) {
|
|
179
414
|
const baseName = ctx.pkgName;
|
|
180
415
|
const names = deriveNames(baseName);
|
|
181
416
|
const dirs = deriveDirs(ctx.targetDir, baseName);
|
|
417
|
+
const existingWorkspace = await findWorkspaceRoot(dirs.root);
|
|
418
|
+
const workspaceRootDir = existingWorkspace?.rootDir ?? dirs.root;
|
|
419
|
+
const workspacePattern = workspacePatternForPackagesRoot(workspaceRootDir, dirs.packagesRoot);
|
|
420
|
+
const stackRootPattern = workspacePatternForStackRoot(workspaceRootDir, dirs.root);
|
|
421
|
+
const workspacePatterns = stackRootPattern
|
|
422
|
+
? [workspacePattern, stackRootPattern]
|
|
423
|
+
: [workspacePattern];
|
|
424
|
+
if (existingWorkspace) {
|
|
425
|
+
for (const pattern of workspacePatterns) {
|
|
426
|
+
// eslint-disable-next-line no-await-in-loop
|
|
427
|
+
await ensureWorkspacePattern(existingWorkspace, pattern);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
182
430
|
// Root workspace files
|
|
183
|
-
await scaffoldRootFiles(dirs.root, baseName, names, dirs
|
|
431
|
+
await scaffoldRootFiles(dirs.root, baseName, names, dirs, {
|
|
432
|
+
includeWorkspaceFiles: !existingWorkspace,
|
|
433
|
+
workspacePatterns,
|
|
434
|
+
});
|
|
184
435
|
// Contract package (reuse contract variant)
|
|
185
436
|
await contractVariant.scaffold({
|
|
186
437
|
targetDir: dirs.contract,
|
|
@@ -197,6 +448,7 @@ export const fullstackVariant = {
|
|
|
197
448
|
targetDir: dirs.client,
|
|
198
449
|
pkgName: names.client,
|
|
199
450
|
contractName: names.contract,
|
|
451
|
+
clientKind: ctx.clientKind ?? DEFAULT_CLIENT_KIND,
|
|
200
452
|
});
|
|
201
453
|
// Docker helper package (reuse docker variant)
|
|
202
454
|
await dockerVariant.scaffold({
|