@emeryld/manager 0.4.2 → 0.4.4

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.
@@ -1,4 +1,4 @@
1
- import { mkdir, readdir, readFile, stat } from 'node:fs/promises';
1
+ import { mkdir, readdir, readFile, rm, stat } from 'node:fs/promises';
2
2
  import { spawn } from 'node:child_process';
3
3
  import path from 'node:path';
4
4
  import { stdin as input } from 'node:process';
@@ -105,6 +105,10 @@ function parseCreateCliArgs(argv) {
105
105
  options.skipBuild = true;
106
106
  continue;
107
107
  }
108
+ if (arg === '--reset') {
109
+ options.reset = true;
110
+ continue;
111
+ }
108
112
  if (arg === '--help' || arg === '-h') {
109
113
  options.help = true;
110
114
  continue;
@@ -123,6 +127,7 @@ function printCreateHelp() {
123
127
  console.log(' pnpm manager-cli create --describe rrr-server');
124
128
  console.log(' pnpm manager-cli create --variant rrr-client --dir packages/rrr-client --name @scope/client');
125
129
  console.log(' pnpm manager-cli create --variant rrr-server --contract @scope/contract --skip-install');
130
+ console.log(' pnpm manager-cli create --variant rrr-server --reset # blow away an existing target before scaffolding');
126
131
  console.log('');
127
132
  console.log('Flags:');
128
133
  console.log(' --list, -l Show available templates');
@@ -131,16 +136,28 @@ function printCreateHelp() {
131
136
  console.log(' --dir, --path, -p Target directory (skips path prompt)');
132
137
  console.log(' --name, -n Package name (skips name prompt)');
133
138
  console.log(' --contract Contract import to inject (server/client variants)');
139
+ console.log(' --reset Remove the target directory if it already exists');
134
140
  console.log(' --skip-install Do not run pnpm install after scaffolding');
135
141
  console.log(' --skip-build Skip build after scaffolding');
136
142
  console.log(' --help, -h Show this help');
137
143
  }
138
- async function ensureTargetDir(targetDir) {
144
+ async function ensureTargetDir(targetDir, options) {
145
+ const resolvedTarget = path.resolve(targetDir);
146
+ const shouldReset = options?.reset ?? false;
147
+ if (shouldReset && resolvedTarget === workspaceRoot) {
148
+ throw new Error('Refusing to reset the workspace root directory.');
149
+ }
139
150
  try {
140
151
  const stats = await stat(targetDir);
141
152
  if (!stats.isDirectory()) {
142
153
  throw new Error(`Target "${targetDir}" exists and is not a directory.`);
143
154
  }
155
+ if (shouldReset) {
156
+ logGlobal(`Resetting existing target ${path.relative(workspaceRoot, resolvedTarget) || '.'}…`, colors.yellow);
157
+ await rm(resolvedTarget, { recursive: true, force: true });
158
+ await mkdir(resolvedTarget, { recursive: true });
159
+ return;
160
+ }
144
161
  const entries = await readdir(targetDir);
145
162
  if (entries.length > 0) {
146
163
  logGlobal(`Target ${path.relative(workspaceRoot, targetDir)} is not empty; existing files will be preserved.`, colors.yellow);
@@ -150,7 +167,7 @@ async function ensureTargetDir(targetDir) {
150
167
  if (error &&
151
168
  typeof error === 'object' &&
152
169
  error.code === 'ENOENT') {
153
- await mkdir(targetDir, { recursive: true });
170
+ await mkdir(resolvedTarget, { recursive: true });
154
171
  return;
155
172
  }
156
173
  throw error;
@@ -289,6 +306,10 @@ async function promptForTargetDir(fallback) {
289
306
  const normalized = answer || fallback;
290
307
  return path.resolve(workspaceRoot, normalized);
291
308
  }
309
+ /**
310
+ * ✅ Build solution #1: after install, build the workspace graph (or at least deps)
311
+ * ✅ Keeps a safe fallback to the old "build just the new package" behavior.
312
+ */
292
313
  async function postCreateTasks(targetDir, options) {
293
314
  if (options?.skipInstall) {
294
315
  logGlobal('Skipping pnpm install (flag).', colors.dim);
@@ -306,6 +327,27 @@ async function postCreateTasks(targetDir, options) {
306
327
  logGlobal('Skipping build (flag).', colors.dim);
307
328
  return;
308
329
  }
330
+ // Prefer building the dependency graph rooted at the new package (fast),
331
+ // fall back to building the whole workspace, then fall back to old behavior.
332
+ try {
333
+ if (options?.pkgName) {
334
+ logGlobal(`Building workspace deps for ${options.pkgName}…`, colors.cyan);
335
+ await runCommand('pnpm', ['-r', '--filter', `${options.pkgName}...`, 'build'], workspaceRoot);
336
+ return;
337
+ }
338
+ }
339
+ catch (error) {
340
+ logGlobal(`Filtered workspace build failed; will try full workspace build: ${error instanceof Error ? error.message : String(error)}`, colors.yellow);
341
+ }
342
+ try {
343
+ logGlobal('Building full workspace…', colors.cyan);
344
+ await runCommand('pnpm', ['-r', 'build'], workspaceRoot);
345
+ return;
346
+ }
347
+ catch (error) {
348
+ logGlobal(`Full workspace build failed; falling back to building only the new package: ${error instanceof Error ? error.message : String(error)}`, colors.yellow);
349
+ }
350
+ // Old behavior fallback: build only the new package if it has a build script
309
351
  try {
310
352
  const pkgJsonPath = path.join(targetDir, 'package.json');
311
353
  const pkgRaw = await readFile(pkgJsonPath, 'utf8');
@@ -329,12 +371,13 @@ async function gatherTarget(initial = {}) {
329
371
  ? await askLine(`Package name? (${fallbackName}): `)
330
372
  : initial.pkgName;
331
373
  const pkgName = (nameAnswer || fallbackName).trim() || fallbackName;
332
- await ensureTargetDir(targetDir);
374
+ await ensureTargetDir(targetDir, { reset: initial.reset });
333
375
  return {
334
376
  variant,
335
377
  targetDir,
336
378
  pkgName,
337
379
  contractName: initial.contractName,
380
+ reset: initial.reset,
338
381
  };
339
382
  }
340
383
  export async function createRrrPackage(options = {}) {
@@ -348,6 +391,7 @@ export async function createRrrPackage(options = {}) {
348
391
  await postCreateTasks(target.targetDir, {
349
392
  skipInstall: options.skipInstall,
350
393
  skipBuild: options.skipBuild ?? options.skipInstall,
394
+ pkgName: target.pkgName,
351
395
  });
352
396
  logGlobal('Scaffold complete. Install/build steps were attempted; ready to run!', colors.green);
353
397
  }
@@ -381,6 +425,7 @@ export async function runCreatePackageCli(argv) {
381
425
  targetDir,
382
426
  pkgName: parsed.pkgName,
383
427
  contractName: parsed.contractName,
428
+ reset: parsed.reset,
384
429
  skipInstall: parsed.skipInstall,
385
430
  skipBuild: parsed.skipBuild ?? parsed.skipInstall,
386
431
  });
@@ -1,6 +1,15 @@
1
+ import { readFileSync } from 'node:fs';
1
2
  import { access, mkdir, writeFile } from 'node:fs/promises';
2
3
  import path from 'node:path';
3
4
  export const workspaceRoot = process.cwd();
5
+ function pathExists(target) {
6
+ return access(target)
7
+ .then(() => true)
8
+ .catch(() => false);
9
+ }
10
+ function toPosixPath(value) {
11
+ return value.split(path.sep).join('/');
12
+ }
4
13
  export async function writeFileIfMissing(baseDir, relative, contents) {
5
14
  const fullPath = path.join(baseDir, relative);
6
15
  await mkdir(path.dirname(fullPath), { recursive: true });
@@ -23,7 +32,8 @@ export async function writeFileIfMissing(baseDir, relative, contents) {
23
32
  return 'created';
24
33
  }
25
34
  export function baseTsConfig(options) {
26
- return `${JSON.stringify({
35
+ const config = {
36
+ ...(options?.extends ? { extends: options.extends } : {}),
27
37
  compilerOptions: {
28
38
  target: 'ES2020',
29
39
  module: 'NodeNext',
@@ -41,11 +51,15 @@ export function baseTsConfig(options) {
41
51
  },
42
52
  include: options?.include ?? ['src/**/*'],
43
53
  exclude: options?.exclude ?? ['dist', 'node_modules'],
44
- }, null, 2)}\n`;
54
+ };
55
+ return `${JSON.stringify(config, null, 2)}\n`;
45
56
  }
46
57
  export function baseEslintConfig(tsconfigPath = './tsconfig.json') {
47
58
  return `import tseslint from 'typescript-eslint'
48
59
  import prettierPlugin from 'eslint-plugin-prettier'
60
+ import { fileURLToPath } from 'node:url'
61
+
62
+ const __dirname = fileURLToPath(new URL('.', import.meta.url))
49
63
 
50
64
  export default tseslint.config(
51
65
  { ignores: ['dist', 'node_modules'] },
@@ -55,7 +69,7 @@ export default tseslint.config(
55
69
  languageOptions: {
56
70
  parserOptions: {
57
71
  project: ${JSON.stringify(tsconfigPath)},
58
- tsconfigRootDir: import.meta.dirname,
72
+ tsconfigRootDir: __dirname,
59
73
  },
60
74
  },
61
75
  plugins: {
@@ -125,6 +139,8 @@ const DEFAULT_GITIGNORE_ENTRIES = [
125
139
  '.env',
126
140
  'coverage',
127
141
  '*.log',
142
+ '.vscode',
143
+ '.husky',
128
144
  ];
129
145
  export function gitignoreFrom(entries = DEFAULT_GITIGNORE_ENTRIES) {
130
146
  return entries.join('\n');
@@ -199,6 +215,10 @@ function stripUndefined(obj) {
199
215
  }
200
216
  export function basePackageJson(options) {
201
217
  const applyDefaults = options.useDefaults ?? true;
218
+ const inheritPackageManager = options.inheritPackageManager ?? true;
219
+ const packageManager = inheritPackageManager && !options.extraFields?.packageManager
220
+ ? readRootPackageManager()
221
+ : undefined;
202
222
  const pkg = stripUndefined({
203
223
  name: options.name,
204
224
  version: options.version ?? '0.1.0',
@@ -221,7 +241,47 @@ export function basePackageJson(options) {
221
241
  dependencies: options.dependencies,
222
242
  devDependencies: options.devDependencies,
223
243
  'lint-staged': LINT_STAGED_CONFIG,
244
+ packageManager,
224
245
  ...options.extraFields,
225
246
  });
226
247
  return `${JSON.stringify(pkg, null, 2)}\n`;
227
248
  }
249
+ function readRootPackageManager() {
250
+ try {
251
+ const raw = readFileSync(path.join(workspaceRoot, 'package.json'), 'utf8');
252
+ const pkg = JSON.parse(raw);
253
+ return pkg.packageManager;
254
+ }
255
+ catch (error) {
256
+ if (error &&
257
+ typeof error === 'object' &&
258
+ error.code !== 'ENOENT') {
259
+ console.warn(`Could not read root package.json for packageManager: ${error instanceof Error ? error.message : String(error)}`);
260
+ }
261
+ return undefined;
262
+ }
263
+ }
264
+ async function resolveRootTsconfig() {
265
+ const candidates = ['tsconfig.base.json', 'tsconfig.json'];
266
+ for (const candidate of candidates) {
267
+ const fullPath = path.join(workspaceRoot, candidate);
268
+ if (await pathExists(fullPath)) {
269
+ return fullPath;
270
+ }
271
+ }
272
+ return undefined;
273
+ }
274
+ export async function packageTsConfig(targetDir, options) {
275
+ const extendsFromRoot = options?.extendsFromRoot ?? true;
276
+ let extendsPath;
277
+ if (extendsFromRoot) {
278
+ const rootConfig = await resolveRootTsconfig();
279
+ if (rootConfig) {
280
+ const relative = path.relative(targetDir, rootConfig);
281
+ if (relative !== '') {
282
+ extendsPath = toPosixPath(relative || './tsconfig.json');
283
+ }
284
+ }
285
+ }
286
+ return baseTsConfig({ ...options, extends: extendsPath });
287
+ }
@@ -1,4 +1,4 @@
1
- import { BASE_LINT_DEV_DEPENDENCIES, basePackageFiles, basePackageJson, buildReadme, baseScripts, baseTsConfig, writeFileIfMissing, } from '../shared.js';
1
+ import { BASE_LINT_DEV_DEPENDENCIES, basePackageFiles, basePackageJson, buildReadme, baseScripts, packageTsConfig, writeFileIfMissing, } from '../shared.js';
2
2
  const CLIENT_SCRIPTS = [
3
3
  'dev',
4
4
  'build',
@@ -45,10 +45,14 @@ export function clientPackageJson(name, contractName = CONTRACT_IMPORT_PLACEHOLD
45
45
  },
46
46
  });
47
47
  }
48
- function clientFiles(pkgName, contractImport) {
48
+ async function clientFiles(pkgName, contractImport, targetDir) {
49
+ const tsconfig = await packageTsConfig(targetDir, {
50
+ lib: ['ES2020', 'DOM'],
51
+ types: ['node'],
52
+ });
49
53
  return {
50
54
  'package.json': clientPackageJson(pkgName, contractImport),
51
- 'tsconfig.json': baseTsConfig({ lib: ['ES2020', 'DOM'], types: ['node'] }),
55
+ 'tsconfig.json': tsconfig,
52
56
  ...basePackageFiles(),
53
57
  'src/index.ts': clientIndexTs(contractImport),
54
58
  'README.md': buildReadme({
@@ -92,7 +96,7 @@ export const clientVariant = {
92
96
  ],
93
97
  async scaffold(ctx) {
94
98
  const contractImport = ctx.contractName ?? CONTRACT_IMPORT_PLACEHOLDER;
95
- const files = clientFiles(ctx.pkgName, contractImport);
99
+ const files = await clientFiles(ctx.pkgName, contractImport, ctx.targetDir);
96
100
  for (const [relative, contents] of Object.entries(files)) {
97
101
  // eslint-disable-next-line no-await-in-loop
98
102
  await writeFileIfMissing(ctx.targetDir, relative, contents);
@@ -1,4 +1,4 @@
1
- import { BASE_LINT_DEV_DEPENDENCIES, basePackageFiles, basePackageJson, buildReadme, baseScripts, baseTsConfig, writeFileIfMissing, } from '../shared.js';
1
+ import { BASE_LINT_DEV_DEPENDENCIES, basePackageFiles, basePackageJson, buildReadme, baseScripts, packageTsConfig, writeFileIfMissing, } from '../shared.js';
2
2
  const CONTRACT_SCRIPTS = [
3
3
  'dev',
4
4
  'build',
@@ -90,7 +90,8 @@ function contractPackageJson(name) {
90
90
  import: './dist/index.js',
91
91
  },
92
92
  },
93
- scripts: baseScripts('tsx watch src/index.ts'),
93
+ // Dev now *builds/updates dist + .d.ts continuously*
94
+ scripts: baseScripts('tsc -p tsconfig.json --watch --preserveWatchOutput'),
94
95
  dependencies: {
95
96
  '@emeryld/rrroutes-contract': '^2.5.2',
96
97
  zod: '^4.2.1',
@@ -100,10 +101,11 @@ function contractPackageJson(name) {
100
101
  },
101
102
  });
102
103
  }
103
- function contractFiles(pkgName) {
104
+ async function contractFiles(pkgName, targetDir) {
105
+ const tsconfig = await packageTsConfig(targetDir);
104
106
  return {
105
107
  'package.json': contractPackageJson(pkgName),
106
- 'tsconfig.json': baseTsConfig(),
108
+ 'tsconfig.json': tsconfig,
107
109
  ...basePackageFiles(),
108
110
  'src/index.ts': CONTRACT_TS,
109
111
  'README.md': buildReadme({
@@ -141,7 +143,7 @@ export const contractVariant = {
141
143
  'Edit src/index.ts to define routes and socket events; exports registry/socket config.',
142
144
  ],
143
145
  async scaffold(ctx) {
144
- const files = contractFiles(ctx.pkgName);
146
+ const files = await contractFiles(ctx.pkgName, ctx.targetDir);
145
147
  for (const [relative, contents] of Object.entries(files)) {
146
148
  // eslint-disable-next-line no-await-in-loop
147
149
  await writeFileIfMissing(ctx.targetDir, relative, contents);
@@ -1,4 +1,4 @@
1
- import { BASE_LINT_DEV_DEPENDENCIES, basePackageFiles, basePackageJson, buildReadme, baseScripts, baseTsConfig, writeFileIfMissing, } from '../shared.js';
1
+ import { BASE_LINT_DEV_DEPENDENCIES, basePackageFiles, basePackageJson, buildReadme, baseScripts, packageTsConfig, writeFileIfMissing, } from '../shared.js';
2
2
  const DOCKER_SCRIPTS = [
3
3
  'dev',
4
4
  'build',
@@ -41,7 +41,7 @@ function dockerPackageJson(name) {
41
41
  '@types/cors': '^2.8.5',
42
42
  '@types/express': '^5.0.6',
43
43
  '@types/node': '^24.10.2',
44
- 'docker-cli-js': '^3.0.9',
44
+ 'docker-cli-js': '^2.10.0',
45
45
  },
46
46
  });
47
47
  }
@@ -95,10 +95,11 @@ EXPOSE 3000
95
95
  CMD ["node", "dist/index.js"]
96
96
  `;
97
97
  }
98
- function dockerFiles(pkgName) {
98
+ async function dockerFiles(pkgName, targetDir) {
99
+ const tsconfig = await packageTsConfig(targetDir, { types: ['node'] });
99
100
  return {
100
101
  'package.json': dockerPackageJson(pkgName),
101
- 'tsconfig.json': baseTsConfig({ types: ['node'] }),
102
+ 'tsconfig.json': tsconfig,
102
103
  'src/index.ts': dockerIndexTs(),
103
104
  'scripts/docker.ts': dockerCliScript(pkgName),
104
105
  '.dockerignore': DOCKER_DOCKERIGNORE,
@@ -190,8 +191,8 @@ async function main() {
190
191
  async function safe(run: () => Promise<unknown>) {
191
192
  try {
192
193
  await run()
193
- } catch (error) {
194
- console.warn(String(error))
194
+ } catch (error: unknown) {
195
+ console.warn(error instanceof Error ? error.message : String(error))
195
196
  }
196
197
  }
197
198
 
@@ -211,8 +212,8 @@ function printHelp() {
211
212
  )
212
213
  }
213
214
 
214
- main().catch((err) => {
215
- console.error(err)
215
+ main().catch((error: unknown) => {
216
+ console.error(error instanceof Error ? error.message : String(error))
216
217
  process.exit(1)
217
218
  })
218
219
  `;
@@ -234,7 +235,7 @@ export const dockerVariant = {
234
235
  'scripts/docker.ts wraps common docker commands with consistent naming.',
235
236
  ],
236
237
  async scaffold(ctx) {
237
- const files = dockerFiles(ctx.pkgName);
238
+ const files = await dockerFiles(ctx.pkgName, ctx.targetDir);
238
239
  for (const [relative, contents] of Object.entries(files)) {
239
240
  // eslint-disable-next-line no-await-in-loop
240
241
  await writeFileIfMissing(ctx.targetDir, relative, contents);
@@ -1,4 +1,4 @@
1
- import { BASE_LINT_DEV_DEPENDENCIES, basePackageFiles, basePackageJson, buildReadme, baseScripts, baseTsConfig, writeFileIfMissing, } from '../shared.js';
1
+ import { BASE_LINT_DEV_DEPENDENCIES, basePackageFiles, basePackageJson, buildReadme, baseScripts, packageTsConfig, writeFileIfMissing, } from '../shared.js';
2
2
  const EMPTY_SCRIPTS = [
3
3
  'dev',
4
4
  'build',
@@ -19,10 +19,11 @@ function emptyPackageJson(name) {
19
19
  },
20
20
  });
21
21
  }
22
- function emptyFiles(pkgName) {
22
+ async function emptyFiles(pkgName, targetDir) {
23
+ const tsconfig = await packageTsConfig(targetDir, { types: ['node'] });
23
24
  return {
24
25
  'package.json': emptyPackageJson(pkgName),
25
- 'tsconfig.json': baseTsConfig({ types: ['node'] }),
26
+ 'tsconfig.json': tsconfig,
26
27
  ...basePackageFiles(),
27
28
  'src/index.ts': "export const hello = 'world'\n",
28
29
  'README.md': buildReadme({
@@ -55,7 +56,7 @@ export const emptyVariant = {
55
56
  scripts: EMPTY_SCRIPTS,
56
57
  notes: ['Start coding in src/index.ts; everything else is wired up.'],
57
58
  async scaffold(ctx) {
58
- const files = emptyFiles(ctx.pkgName);
59
+ const files = await emptyFiles(ctx.pkgName, ctx.targetDir);
59
60
  for (const [relative, contents] of Object.entries(files)) {
60
61
  // eslint-disable-next-line no-await-in-loop
61
62
  await writeFileIfMissing(ctx.targetDir, relative, contents);
@@ -1,4 +1,4 @@
1
- import { BASE_LINT_DEV_DEPENDENCIES, basePackageFiles, basePackageJson, buildReadme, baseScripts, baseTsConfig, writeFileIfMissing, } from '../shared.js';
1
+ import { BASE_LINT_DEV_DEPENDENCIES, basePackageFiles, basePackageJson, buildReadme, baseScripts, packageTsConfig, writeFileIfMissing, } from '../shared.js';
2
2
  const SERVER_SCRIPTS = [
3
3
  'dev',
4
4
  'build',
@@ -81,10 +81,11 @@ export function serverPackageJson(name, contractName = CONTRACT_IMPORT_PLACEHOLD
81
81
  },
82
82
  });
83
83
  }
84
- function serverFiles(pkgName, contractImport) {
84
+ async function serverFiles(pkgName, contractImport, targetDir) {
85
+ const tsconfig = await packageTsConfig(targetDir, { types: ['node'] });
85
86
  return {
86
87
  'package.json': serverPackageJson(pkgName, contractImport),
87
- 'tsconfig.json': baseTsConfig({ types: ['node'] }),
88
+ 'tsconfig.json': tsconfig,
88
89
  ...basePackageFiles(),
89
90
  'src/index.ts': serverIndexTs(contractImport),
90
91
  '.env.example': 'PORT=4000\n',
@@ -130,7 +131,7 @@ export const serverVariant = {
130
131
  ],
131
132
  async scaffold(ctx) {
132
133
  const contractImport = ctx.contractName ?? CONTRACT_IMPORT_PLACEHOLDER;
133
- const files = serverFiles(ctx.pkgName, contractImport);
134
+ const files = await serverFiles(ctx.pkgName, contractImport, ctx.targetDir);
134
135
  for (const [relative, contents] of Object.entries(files)) {
135
136
  // eslint-disable-next-line no-await-in-loop
136
137
  await writeFileIfMissing(ctx.targetDir, relative, contents);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emeryld/manager",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Interactive manager for pnpm monorepos (update/test/build/publish).",
5
5
  "license": "MIT",
6
6
  "type": "module",