@emeryld/manager 0.4.6 → 0.5.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 CHANGED
@@ -30,7 +30,7 @@ Use `pnpm manager-cli create --describe <variant>` to print the scripts, key fil
30
30
  - `--skip-install` / `--skip-build` — bypass post-create steps when you want to install/build later
31
31
 
32
32
  ## Release helper (existing packages)
33
- - Run from the workspace root where `packages/` lives.
33
+ - Run from the workspace root; the CLI discovers any workspace packages with a `package.json`.
34
34
  - `pnpm manager-cli` launches an interactive menu: pick packages, then run update → test → build → publish (or any single step).
35
35
  - Non-interactive publish: `pnpm manager-cli <pkg|all> --non-interactive --bump patch` (see CLI prompts for flags like `--sync` and `--tag`).
36
36
 
@@ -4,6 +4,8 @@ import path from 'node:path';
4
4
  import { stdin as input } from 'node:process';
5
5
  import { askLine, promptSingleKey } from '../prompts.js';
6
6
  import { colors, logGlobal } from '../utils/log.js';
7
+ import { runHelperCli } from '../helper-cli.js';
8
+ import { loadPackages } from '../packages.js';
7
9
  import { SCRIPT_DESCRIPTIONS, workspaceRoot, ensureWorkspaceToolingFiles, } from './shared.js';
8
10
  import { clientVariant } from './variants/client.js';
9
11
  import { contractVariant } from './variants/contract.js';
@@ -24,6 +26,58 @@ function derivePackageName(targetDir) {
24
26
  const base = path.basename(targetDir) || 'rrr-package';
25
27
  return base;
26
28
  }
29
+ async function promptForContractNameWithHelper(title, options) {
30
+ let selection;
31
+ const scripts = options.map((opt) => ({
32
+ name: opt.label,
33
+ description: opt.meta ?? '',
34
+ emoji: '📦',
35
+ handler: () => {
36
+ selection = opt.value;
37
+ },
38
+ }));
39
+ scripts.push({
40
+ name: 'Enter manually',
41
+ emoji: '⌨️',
42
+ description: 'Type a contract package name',
43
+ handler: async () => {
44
+ const manual = await askLine('Contract package name (e.g. @scope/contract): ');
45
+ selection = manual.trim() || undefined;
46
+ },
47
+ });
48
+ scripts.push({
49
+ name: 'Skip (no contract)',
50
+ emoji: '⏭️',
51
+ description: 'Continue without a contract dependency',
52
+ handler: () => {
53
+ selection = undefined;
54
+ },
55
+ });
56
+ await runHelperCli({ title, scripts, argv: [] });
57
+ return selection;
58
+ }
59
+ async function promptForContractName() {
60
+ let packages = [];
61
+ try {
62
+ packages = await loadPackages();
63
+ }
64
+ catch (error) {
65
+ const message = error instanceof Error ? error.message : 'unknown error discovering packages';
66
+ logGlobal(`Could not auto-discover packages (${message}).`, colors.yellow);
67
+ }
68
+ const contractOptions = packages
69
+ .filter((pkg) => pkg.relativeDir !== '.')
70
+ .map((pkg) => ({
71
+ value: pkg.name,
72
+ label: pkg.name,
73
+ meta: pkg.relativeDir,
74
+ }));
75
+ if (contractOptions.length === 0) {
76
+ const manual = await askLine('Contract package name? (Enter to skip, e.g. @scope/contract): ');
77
+ return manual.trim() || undefined;
78
+ }
79
+ return promptForContractNameWithHelper('Select a contract package (or skip)', contractOptions);
80
+ }
27
81
  function resolveVariant(key) {
28
82
  if (!key)
29
83
  return undefined;
@@ -372,11 +426,16 @@ async function gatherTarget(initial = {}) {
372
426
  : initial.pkgName;
373
427
  const pkgName = (nameAnswer || fallbackName).trim() || fallbackName;
374
428
  await ensureTargetDir(targetDir, { reset: initial.reset });
429
+ let contractName = initial.contractName;
430
+ if ((variant.id === 'rrr-server' || variant.id === 'rrr-client') &&
431
+ contractName === undefined) {
432
+ contractName = await promptForContractName();
433
+ }
375
434
  return {
376
435
  variant,
377
436
  targetDir,
378
437
  pkgName,
379
- contractName: initial.contractName,
438
+ contractName,
380
439
  reset: initial.reset,
381
440
  };
382
441
  }
@@ -10,11 +10,19 @@ const CLIENT_SCRIPTS = [
10
10
  'clean',
11
11
  'test',
12
12
  ];
13
- const CONTRACT_IMPORT_PLACEHOLDER = '@your-scope/contract';
14
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'])`;
15
23
  return `import { QueryClient } from '@tanstack/react-query'
16
24
  import { createRouteClient } from '@emeryld/rrroutes-client'
17
- import { registry } from '${contractImport}'
25
+ ${contractImportLine}
18
26
 
19
27
  const baseUrl = process.env.RRR_API_URL ?? 'http://localhost:4000'
20
28
  export const queryClient = new QueryClient()
@@ -25,22 +33,23 @@ export const routeClient = createRouteClient({
25
33
  environment: process.env.NODE_ENV === 'production' ? 'production' : 'development',
26
34
  })
27
35
 
28
- export const healthGet = routeClient.build(registry.byKey['GET /api/health'])
29
- export const healthPost = routeClient.build(registry.byKey['POST /api/health'])
36
+ ${routeExports}
30
37
  `;
31
38
  }
32
- export function clientPackageJson(name, contractName = CONTRACT_IMPORT_PLACEHOLDER, options) {
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:*';
33
47
  return basePackageJson({
34
48
  name,
35
49
  scripts: baseScripts('tsx watch src/index.ts', undefined, {
36
50
  includePrepare: options?.includePrepare,
37
51
  }),
38
- dependencies: {
39
- [contractName]: 'workspace:*',
40
- '@emeryld/rrroutes-client': '^2.5.3',
41
- '@tanstack/react-query': '^5.90.12',
42
- 'socket.io-client': '^4.8.3',
43
- },
52
+ dependencies,
44
53
  devDependencies: {
45
54
  ...BASE_LINT_DEV_DEPENDENCIES,
46
55
  '@types/node': '^24.10.2',
@@ -76,7 +85,9 @@ async function clientFiles(pkgName, contractImport, targetDir) {
76
85
  {
77
86
  title: 'Usage',
78
87
  lines: [
79
- `- Update the contract import in \`src/index.ts\` if needed (${contractImport}).`,
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.',
80
91
  '- Use the exported `queryClient` and built route clients from `src/index.ts`.',
81
92
  ],
82
93
  },
@@ -96,11 +107,12 @@ export const clientVariant = {
96
107
  keyFiles: ['src/index.ts', 'README.md'],
97
108
  scripts: CLIENT_SCRIPTS,
98
109
  notes: [
110
+ 'Pick a contract from discovered workspace packages (or skip) during scaffolding.',
99
111
  'Set the contract import via --contract or by editing src/index.ts.',
100
112
  'Exports query client + typed route builders to plug into React apps.',
101
113
  ],
102
114
  async scaffold(ctx) {
103
- const contractImport = ctx.contractName ?? CONTRACT_IMPORT_PLACEHOLDER;
115
+ const contractImport = ctx.contractName;
104
116
  const files = await clientFiles(ctx.pkgName, contractImport, ctx.targetDir);
105
117
  for (const [relative, contents] of Object.entries(files)) {
106
118
  // eslint-disable-next-line no-await-in-loop
@@ -149,49 +149,21 @@ import { readFile } from 'node:fs/promises'
149
149
  import path from 'node:path'
150
150
  import { fileURLToPath } from 'node:url'
151
151
  import { Docker } from 'docker-cli-js'
152
+ import { runHelperCli } from '@emeryld/manager/dist/helper-cli.js'
152
153
 
153
154
  const __filename = fileURLToPath(import.meta.url)
154
155
  const __dirname = path.dirname(__filename)
155
- const pkgRaw = await readFile(path.join(__dirname, '..', 'package.json'), 'utf8')
156
+ const pkgRoot = path.join(__dirname, '..')
157
+ const pkgRaw = await readFile(path.join(pkgRoot, 'package.json'), 'utf8')
156
158
  const pkg = JSON.parse(pkgRaw) as { name?: string }
157
159
  const image = \`\${pkg.name ?? '${pkgName}'}:latest\`
158
160
  const container =
159
- (pkg.name ?? '${pkgName}').replace(/[^a-z0-9]/gi, '-').replace(/^-+|-+$/g, '') || 'rrr-service'
161
+ (pkg.name ?? '${pkgName}')
162
+ .replace(/[^a-z0-9]/gi, '-')
163
+ .replace(/^-+|-+$/g, '') || 'rrr-service'
160
164
  const port = process.env.PORT ?? '3000'
161
165
  const docker = new Docker({ spawnOptions: { stdio: 'inherit' } })
162
166
 
163
- async function main() {
164
- const [command = 'help'] = process.argv.slice(2)
165
- if (command === 'help') return printHelp()
166
- if (command === 'build') return docker.command(\`build -t \${image} .\`)
167
- if (command === 'up') {
168
- await docker.command(\`build -t \${image} .\`)
169
- return docker.command(\`run -d --rm -p \${port}:\${port} --name \${container} \${image}\`)
170
- }
171
- if (command === 'dev') {
172
- await docker.command(\`build -t \${image} .\`)
173
- await docker.command(\`run -d --rm -p \${port}:\${port} --name \${container} \${image}\`)
174
- return docker.command(\`logs -f \${container}\`)
175
- }
176
- if (command === 'run') {
177
- return docker.command(\`run -d --rm -p \${port}:\${port} --name \${container} \${image}\`)
178
- }
179
- if (command === 'logs') return docker.command(\`logs -f \${container}\`)
180
- if (command === 'stop') return docker.command(\`stop \${container}\`)
181
- if (command === 'clean') {
182
- await safe(() => docker.command(\`stop \${container}\`))
183
- await safe(() => docker.command(\`rm -f \${container}\`))
184
- return
185
- }
186
- if (command === 'reset') {
187
- await safe(() => docker.command(\`stop \${container}\`))
188
- await safe(() => docker.command(\`rm -f \${container}\`))
189
- await safe(() => docker.command(\`rmi -f \${image}\`))
190
- return
191
- }
192
- return printHelp()
193
- }
194
-
195
167
  async function safe(run: () => Promise<unknown>) {
196
168
  try {
197
169
  await run()
@@ -216,9 +188,82 @@ function printHelp() {
216
188
  )
217
189
  }
218
190
 
219
- main().catch((error: unknown) => {
220
- console.error(error instanceof Error ? error.message : String(error))
221
- process.exit(1)
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
+ ],
222
267
  })
223
268
  `;
224
269
  }
@@ -11,14 +11,30 @@ const SERVER_SCRIPTS = [
11
11
  'test',
12
12
  'start',
13
13
  ];
14
- const CONTRACT_IMPORT_PLACEHOLDER = '@your-scope/contract';
15
14
  export function serverIndexTs(contractImport) {
15
+ const contractImportLine = contractImport
16
+ ? `import { registry } from '${contractImport}'`
17
+ : "// import { registry } from '@your-scope/contract'";
18
+ const registerBlock = contractImport
19
+ ? `routes.registerControllers(registry, {
20
+ 'GET /api/health': {
21
+ handler: async ({ ctx }) => ({
22
+ out: {
23
+ status: 'ok',
24
+ requestId: ctx.requestId,
25
+ at: new Date().toISOString(),
26
+ },
27
+ }),
28
+ },
29
+ })`
30
+ : `// TODO: import your contract registry and register controllers
31
+ // routes.registerControllers(registry)`;
16
32
  return `import 'dotenv/config'
17
33
  import http from 'node:http'
18
34
  import express from 'express'
19
35
  import cors from 'cors'
20
36
  import { createRRRoute } from '@emeryld/rrroutes-server'
21
- import { registry } from '${contractImport}'
37
+ ${contractImportLine}
22
38
 
23
39
  const app = express()
24
40
  app.use(cors({ origin: '*', credentials: true }))
@@ -38,17 +54,7 @@ const routes = createRRRoute(app, {
38
54
  : undefined,
39
55
  })
40
56
 
41
- routes.registerControllers(registry, {
42
- 'GET /api/health': {
43
- handler: async ({ ctx }) => ({
44
- out: {
45
- status: 'ok',
46
- requestId: ctx.requestId,
47
- at: new Date().toISOString(),
48
- },
49
- }),
50
- },
51
- })
57
+ ${registerBlock}
52
58
 
53
59
  const PORT = Number.parseInt(process.env.PORT ?? '4000', 10)
54
60
  const server = http.createServer(app)
@@ -58,21 +64,23 @@ server.listen(PORT, () => {
58
64
  })
59
65
  `;
60
66
  }
61
- export function serverPackageJson(name, contractName = CONTRACT_IMPORT_PLACEHOLDER, options) {
67
+ export function serverPackageJson(name, contractName, options) {
68
+ const dependencies = {
69
+ '@emeryld/rrroutes-server': '^2.4.1',
70
+ cors: '^2.8.5',
71
+ dotenv: '^16.4.5',
72
+ express: '^5.1.0',
73
+ zod: '^4.2.1',
74
+ };
75
+ if (contractName)
76
+ dependencies[contractName] = 'workspace:*';
62
77
  return basePackageJson({
63
78
  name,
64
79
  private: false,
65
80
  scripts: baseScripts('tsx watch --env-file .env src/index.ts', {
66
81
  start: 'node dist/index.js',
67
82
  }, { includePrepare: options?.includePrepare }),
68
- dependencies: {
69
- [contractName]: 'workspace:*',
70
- '@emeryld/rrroutes-server': '^2.4.1',
71
- cors: '^2.8.5',
72
- dotenv: '^16.4.5',
73
- express: '^5.1.0',
74
- zod: '^4.2.1',
75
- },
83
+ dependencies,
76
84
  devDependencies: {
77
85
  ...BASE_LINT_DEV_DEPENDENCIES,
78
86
  '@types/cors': '^2.8.5',
@@ -110,7 +118,9 @@ async function serverFiles(pkgName, contractImport, targetDir) {
110
118
  {
111
119
  title: 'Usage',
112
120
  lines: [
113
- `- Update the contract import in \`src/index.ts\` if needed (${contractImport}).`,
121
+ contractImport
122
+ ? `- Contract wired to ${contractImport}; adjust the import in \`src/index.ts\` if needed.`
123
+ : '- Add your contract import to `src/index.ts` when ready and register controllers.',
114
124
  '- Start compiled server: `npm run start` after building.',
115
125
  ],
116
126
  },
@@ -130,11 +140,12 @@ export const serverVariant = {
130
140
  keyFiles: ['src/index.ts', '.env.example', 'README.md'],
131
141
  scripts: SERVER_SCRIPTS,
132
142
  notes: [
133
- 'Set the contract import via --contract or by editing src/index.ts.',
143
+ 'Pick a contract from discovered workspace packages (or skip) during scaffolding.',
144
+ 'Set/adjust the contract import via --contract or by editing src/index.ts.',
134
145
  'Includes start script for compiled output and dotenv-ready dev script.',
135
146
  ],
136
147
  async scaffold(ctx) {
137
- const contractImport = ctx.contractName ?? CONTRACT_IMPORT_PLACEHOLDER;
148
+ const contractImport = ctx.contractName;
138
149
  const files = await serverFiles(ctx.pkgName, contractImport, ctx.targetDir);
139
150
  for (const [relative, contents] of Object.entries(files)) {
140
151
  // eslint-disable-next-line no-await-in-loop
package/dist/menu.js CHANGED
@@ -4,7 +4,8 @@ import { releaseMultiple, releaseSingle } from './release.js';
4
4
  import { getOrderedPackages } from './packages.js';
5
5
  import { runHelperCli } from './helper-cli.js';
6
6
  import { ensureWorkingTreeCommitted } from './preflight.js';
7
- export function makeStepEntries(targets, packages, state) {
7
+ import { run } from './utils/run.js';
8
+ function makeManagerStepEntries(targets, packages, state) {
8
9
  return [
9
10
  {
10
11
  name: 'update dependencies',
@@ -84,12 +85,29 @@ export function makeStepEntries(targets, packages, state) {
84
85
  },
85
86
  ];
86
87
  }
88
+ function makePackageScriptEntries(pkg) {
89
+ const scripts = pkg.json?.scripts ?? {};
90
+ const entries = [];
91
+ Object.entries(scripts)
92
+ .sort(([a], [b]) => a.localeCompare(b))
93
+ .forEach(([name, command]) => {
94
+ entries.push({
95
+ name,
96
+ emoji: '▶️',
97
+ description: typeof command === 'string' ? command : '',
98
+ handler: async () => {
99
+ await run('pnpm', ['run', name], { cwd: pkg.path });
100
+ },
101
+ });
102
+ });
103
+ return entries;
104
+ }
87
105
  export function buildPackageSelectionMenu(packages, onStepComplete) {
88
106
  const ordered = getOrderedPackages(packages);
89
107
  const entries = ordered.map((pkg) => ({
90
- name: pkg.substitute,
108
+ name: pkg.name ?? pkg.substitute ?? pkg.dirName,
91
109
  emoji: colors[pkg.color]('●'),
92
- description: pkg.name ?? pkg.dirName,
110
+ description: pkg.relativeDir ?? pkg.dirName,
93
111
  handler: async () => {
94
112
  const step = await runStepLoop([pkg], packages);
95
113
  onStepComplete?.(step);
@@ -112,6 +130,46 @@ export function buildPackageSelectionMenu(packages, onStepComplete) {
112
130
  // This removes the infinite loop and returns to the step menu after each run.
113
131
  export async function runStepLoop(targets, packages) {
114
132
  const state = {};
133
+ // Single package: show combined menu (manager actions + package.json scripts)
134
+ if (targets.length === 1) {
135
+ const pkg = targets[0];
136
+ // eslint-disable-next-line no-constant-condition
137
+ while (true) {
138
+ const scriptEntries = makePackageScriptEntries(pkg);
139
+ const entries = [
140
+ {
141
+ name: 'manager actions',
142
+ emoji: globalEmoji,
143
+ description: 'update/test/build/publish',
144
+ handler: async () => {
145
+ const managerEntries = makeManagerStepEntries(targets, packages, state);
146
+ await runHelperCli({
147
+ title: `Manager actions for ${pkg.name}`,
148
+ scripts: managerEntries,
149
+ argv: [],
150
+ });
151
+ },
152
+ },
153
+ ...scriptEntries,
154
+ {
155
+ name: 'back',
156
+ emoji: '↩️',
157
+ description: 'Pick packages again',
158
+ handler: () => {
159
+ state.lastStep = 'back';
160
+ },
161
+ },
162
+ ];
163
+ await runHelperCli({
164
+ title: `Actions for ${pkg.name}`,
165
+ scripts: entries,
166
+ argv: [],
167
+ });
168
+ if (state.lastStep === 'back')
169
+ return state.lastStep;
170
+ // loop to keep showing menu
171
+ }
172
+ }
115
173
  // Loop shows the step menu and executes a chosen action once.
116
174
  // After the handler completes, show the menu again.
117
175
  // Selecting "back" breaks and returns control to the package picker.
@@ -119,7 +177,7 @@ export async function runStepLoop(targets, packages) {
119
177
  // eslint-disable-next-line no-constant-condition
120
178
  while (true) {
121
179
  state.lastStep = undefined;
122
- const entries = makeStepEntries(targets, packages, state);
180
+ const entries = makeManagerStepEntries(targets, packages, state);
123
181
  await runHelperCli({
124
182
  title: `Actions for ${targets.length === 1 ? targets[0].name : 'selected packages'}`,
125
183
  scripts: entries,
package/dist/packages.js CHANGED
@@ -3,7 +3,16 @@ import path from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
4
  import { readdir, readFile } from 'node:fs/promises';
5
5
  const rootDir = process.cwd();
6
- export const packagesDir = path.join(rootDir, 'packages');
6
+ const ignoredDirs = new Set([
7
+ 'node_modules',
8
+ '.git',
9
+ '.turbo',
10
+ '.next',
11
+ 'dist',
12
+ 'build',
13
+ '.cache',
14
+ 'coverage',
15
+ ]);
7
16
  const colorPalette = ['cyan', 'green', 'yellow', 'magenta', 'red'];
8
17
  let manifestState;
9
18
  function manifestFilePath() {
@@ -19,7 +28,7 @@ function normalizeManifestPath(value) {
19
28
  const absolute = path.resolve(rootDir, value || '');
20
29
  let relative = path.relative(rootDir, absolute);
21
30
  if (!relative)
22
- return '';
31
+ return '.';
23
32
  relative = relative.replace(/\\/g, '/');
24
33
  return relative.replace(/^(?:\.\/)+/, '');
25
34
  }
@@ -42,6 +51,35 @@ function deriveSubstitute(name) {
42
51
  .join(' ');
43
52
  return transformed || trimmed;
44
53
  }
54
+ async function findPackageJsonFiles(baseDir) {
55
+ const results = new Set();
56
+ const queue = [baseDir];
57
+ while (queue.length) {
58
+ const current = queue.shift();
59
+ let entries;
60
+ try {
61
+ entries = await readdir(current, { withFileTypes: true });
62
+ }
63
+ catch {
64
+ continue;
65
+ }
66
+ for (const entry of entries) {
67
+ if (entry.isFile() && entry.name === 'package.json') {
68
+ results.add(path.join(current, entry.name));
69
+ }
70
+ }
71
+ for (const entry of entries) {
72
+ if (!entry.isDirectory())
73
+ continue;
74
+ if (entry.isSymbolicLink())
75
+ continue;
76
+ if (ignoredDirs.has(entry.name))
77
+ continue;
78
+ queue.push(path.join(current, entry.name));
79
+ }
80
+ }
81
+ return [...results].sort();
82
+ }
45
83
  async function loadWorkspaceManifest() {
46
84
  const manifestPath = manifestFilePath();
47
85
  try {
@@ -58,33 +96,26 @@ async function loadWorkspaceManifest() {
58
96
  return undefined;
59
97
  }
60
98
  async function inferManifestFromWorkspace() {
61
- try {
62
- const entries = await readdir(packagesDir, { withFileTypes: true });
63
- const manifest = [];
64
- for (const entry of entries) {
65
- if (!entry.isDirectory())
66
- continue;
67
- const pkgJsonPath = path.join(packagesDir, entry.name, 'package.json');
68
- try {
69
- const raw = await readFile(pkgJsonPath, 'utf8');
70
- const json = JSON.parse(raw);
71
- const pkgName = json.name?.trim() || entry.name;
72
- manifest.push({
73
- name: pkgName,
74
- path: normalizeManifestPath(path.relative(rootDir, path.join(packagesDir, entry.name))),
75
- color: colorFromSeed(pkgName),
76
- substitute: deriveSubstitute(pkgName),
77
- });
78
- }
79
- catch {
80
- continue;
81
- }
99
+ const manifest = [];
100
+ const pkgJsonPaths = await findPackageJsonFiles(rootDir);
101
+ for (const pkgJsonPath of pkgJsonPaths) {
102
+ try {
103
+ const raw = await readFile(pkgJsonPath, 'utf8');
104
+ const json = JSON.parse(raw);
105
+ const pkgDir = path.dirname(pkgJsonPath);
106
+ const pkgName = json.name?.trim() || path.basename(pkgDir) || 'package';
107
+ manifest.push({
108
+ name: pkgName,
109
+ path: normalizeManifestPath(path.relative(rootDir, pkgDir)),
110
+ color: colorFromSeed(pkgName),
111
+ substitute: deriveSubstitute(pkgName),
112
+ });
113
+ }
114
+ catch {
115
+ continue;
82
116
  }
83
- return manifest;
84
- }
85
- catch {
86
- return [];
87
117
  }
118
+ return manifest;
88
119
  }
89
120
  function mergeManifestEntries(inferred, overrides) {
90
121
  const normalizedOverrides = new Map();
@@ -117,8 +148,8 @@ function mergeManifestEntries(inferred, overrides) {
117
148
  });
118
149
  return merged;
119
150
  }
120
- async function ensureManifestState() {
121
- if (manifestState)
151
+ async function ensureManifestState(forceReload = false) {
152
+ if (manifestState && !forceReload)
122
153
  return manifestState;
123
154
  const [workspaceManifest, inferred] = await Promise.all([
124
155
  loadWorkspaceManifest(),
@@ -136,35 +167,34 @@ async function ensureManifestState() {
136
167
  return manifestState;
137
168
  }
138
169
  export async function loadPackages() {
139
- const entries = await readdir(packagesDir, { withFileTypes: true });
140
170
  const packages = [];
141
- const { byName, byPath } = await ensureManifestState();
171
+ const { byName, byPath, entries } = await ensureManifestState(true);
142
172
  for (const entry of entries) {
143
- if (!entry.isDirectory())
144
- continue;
145
- const pkgJsonPath = path.join(packagesDir, entry.name, 'package.json');
173
+ const pkgDir = path.resolve(rootDir, entry.path || '.');
174
+ const pkgJsonPath = path.join(pkgDir, 'package.json');
146
175
  try {
147
176
  const raw = await readFile(pkgJsonPath, 'utf8');
148
177
  const json = JSON.parse(raw);
149
- const pkgName = json.name ?? entry.name;
150
- const relativePath = normalizeManifestPath(path.relative(rootDir, path.join(packagesDir, entry.name)));
178
+ const pkgName = json.name?.trim() ?? entry.name;
179
+ const relativePath = normalizeManifestPath(path.relative(rootDir, pkgDir));
151
180
  const meta = byPath.get(relativePath.toLowerCase()) ??
152
- byName.get(pkgName.toLowerCase());
153
- const substitute = meta?.substitute ?? deriveSubstitute(pkgName) ?? entry.name;
181
+ byName.get((pkgName ?? '').toLowerCase());
182
+ const substitute = meta?.substitute ?? deriveSubstitute(pkgName) ?? path.basename(pkgDir);
154
183
  const color = meta?.color ?? colorFromSeed(pkgName);
155
184
  packages.push({
156
- dirName: entry.name,
157
- path: path.join(packagesDir, entry.name),
185
+ dirName: path.basename(pkgDir),
186
+ relativeDir: relativePath,
187
+ path: pkgDir,
158
188
  packageJsonPath: pkgJsonPath,
159
189
  json,
160
190
  version: json.version,
161
- name: pkgName,
191
+ name: pkgName ?? path.basename(pkgDir),
162
192
  substitute,
163
193
  color,
164
194
  });
165
195
  }
166
196
  catch (error) {
167
- console.warn(`Skipping ${entry.name}: ${error}`);
197
+ console.warn(`Skipping ${entry.path || 'workspace root'}: ${String(error)}`);
168
198
  }
169
199
  }
170
200
  return packages;
@@ -175,10 +205,12 @@ export function resolvePackage(packages, key) {
175
205
  const normalized = key.toLowerCase();
176
206
  return packages.find((pkg) => {
177
207
  const dirMatch = pkg.dirName.toLowerCase() === normalized;
208
+ const pathMatch = pkg.relativeDir.toLowerCase() === normalized;
178
209
  const nameMatch = (pkg.name ?? '').toLowerCase() === normalized;
179
210
  const aliasMatch = (pkg.substitute ?? '').toLowerCase() === normalized;
180
- const fuzzyMatch = (pkg.name ?? '').toLowerCase().includes(normalized);
181
- return dirMatch || nameMatch || aliasMatch || fuzzyMatch;
211
+ const fuzzyMatch = (pkg.name ?? '').toLowerCase().includes(normalized) ||
212
+ pkg.relativeDir.toLowerCase().includes(normalized);
213
+ return dirMatch || pathMatch || nameMatch || aliasMatch || fuzzyMatch;
182
214
  });
183
215
  }
184
216
  /**
@@ -194,7 +226,7 @@ export function getOrderedPackages(packages) {
194
226
  for (const p of packages) {
195
227
  if (p.name)
196
228
  byName.set(p.name, p);
197
- byDir.set(p.dirName, p);
229
+ byDir.set(p.relativeDir, p);
198
230
  }
199
231
  // Build adjacency list: edge dep -> pkg (dep must publish first)
200
232
  const depsOf = (p) => {
@@ -216,24 +248,24 @@ export function getOrderedPackages(packages) {
216
248
  .map((n) => byName.get(n))
217
249
  .filter((x) => Boolean(x));
218
250
  };
219
- const nodes = new Set(packages.map((p) => p.dirName));
251
+ const nodes = new Set(packages.map((p) => p.relativeDir));
220
252
  const inDegree = new Map();
221
253
  const adj = new Map();
222
254
  for (const p of packages) {
223
- inDegree.set(p.dirName, 0);
224
- adj.set(p.dirName, new Set());
255
+ inDegree.set(p.relativeDir, 0);
256
+ adj.set(p.relativeDir, new Set());
225
257
  }
226
258
  for (const p of packages) {
227
259
  for (const dep of depsOf(p)) {
228
260
  // dep -> p
229
- if (!nodes.has(dep.dirName))
261
+ if (!nodes.has(dep.relativeDir))
230
262
  continue;
231
- if (dep.dirName === p.dirName)
263
+ if (dep.relativeDir === p.relativeDir)
232
264
  continue;
233
- const set = adj.get(dep.dirName);
234
- if (!set.has(p.dirName)) {
235
- set.add(p.dirName);
236
- inDegree.set(p.dirName, (inDegree.get(p.dirName) ?? 0) + 1);
265
+ const set = adj.get(dep.relativeDir);
266
+ if (!set.has(p.relativeDir)) {
267
+ set.add(p.relativeDir);
268
+ inDegree.set(p.relativeDir, (inDegree.get(p.relativeDir) ?? 0) + 1);
237
269
  }
238
270
  }
239
271
  }
package/dist/prompts.js CHANGED
@@ -1,7 +1,9 @@
1
1
  // src/prompts.js
2
2
  import readline from 'node:readline/promises';
3
3
  import { stdin as input, stdout as output } from 'node:process';
4
- export const publishCliState = { autoConfirmAll: false };
4
+ export const publishCliState = {
5
+ autoDecision: undefined,
6
+ };
5
7
  export function promptSingleKey(message, resolver) {
6
8
  const supportsRawMode = typeof input.setRawMode === 'function' && input.isTTY;
7
9
  if (!supportsRawMode) {
@@ -64,22 +66,26 @@ export async function askLine(question) {
64
66
  }
65
67
  }
66
68
  export async function promptYesNoAll(question) {
67
- if (publishCliState.autoConfirmAll) {
68
- console.log(`${question} (auto-confirmed via "all")`);
69
- return 'yes';
69
+ if (publishCliState.autoDecision) {
70
+ console.log(`${question} (auto-${publishCliState.autoDecision} via "all")`);
71
+ return publishCliState.autoDecision;
70
72
  }
71
- const result = await promptSingleKey(`${question} (y/n/a): `, (key) => {
72
- if (key === 'y')
73
+ // Allow yes/no + "apply to all" variants: y, ya, n, na
74
+ // Use readline-style input to capture multi-character tokens consistently.
75
+ // eslint-disable-next-line no-constant-condition
76
+ while (true) {
77
+ const answer = (await askLine(`${question} (y/ya/n/na): `)).toLowerCase();
78
+ if (answer === 'y')
73
79
  return 'yes';
74
- if (key === 'n')
80
+ if (answer === 'ya') {
81
+ publishCliState.autoDecision = 'yes';
82
+ return 'yes';
83
+ }
84
+ if (answer === 'n')
75
85
  return 'no';
76
- if (key === 'a')
77
- return 'all';
78
- return undefined;
79
- });
80
- if (result === 'all') {
81
- publishCliState.autoConfirmAll = true;
82
- return 'yes';
86
+ if (answer === 'na') {
87
+ publishCliState.autoDecision = 'no';
88
+ return 'no';
89
+ }
83
90
  }
84
- return result;
85
91
  }
package/dist/publish.js CHANGED
@@ -134,9 +134,9 @@ async function main() {
134
134
  // If user provided non-interactive flags, run headless path
135
135
  if (parsed.nonInteractive) {
136
136
  if (packages.length === 0) {
137
- throw new Error('No packages found in ./packages');
137
+ throw new Error('No packages with a package.json found in this workspace');
138
138
  }
139
- publishCliState.autoConfirmAll = true;
139
+ publishCliState.autoDecision = 'yes';
140
140
  if (!parsed.selectionArg) {
141
141
  throw new Error('Non-interactive mode requires a package selection: <pkg> or "all".');
142
142
  }
@@ -153,7 +153,7 @@ async function main() {
153
153
  return;
154
154
  }
155
155
  if (packages.length === 0) {
156
- logGlobal('No packages found in ./packages. Use "Create package" to scaffold one.', colors.yellow);
156
+ logGlobal('No packages found (no package.json discovered). Use "Create package" to scaffold one.', colors.yellow);
157
157
  }
158
158
  // Interactive flow (unchanged): selection menu then step menu
159
159
  if (parsed.selectionArg) {
package/dist/release.js CHANGED
@@ -155,8 +155,8 @@ export async function promptVersionStrategy(targetPkg, selection, opts) {
155
155
  return viaOpts;
156
156
  console.log('\nCurrent package versions:');
157
157
  for (const pkg of selection) {
158
- const marker = selection.length === 1 && pkg.dirName === targetPkg.dirName ? '*' : '•';
159
- console.log(` ${marker} ${formatPkgName(pkg)} ${pkg.dirName} ${colors.yellow(pkg.version)}`);
158
+ const marker = selection.length === 1 && pkg.relativeDir === targetPkg.relativeDir ? '*' : '•';
159
+ console.log(` ${marker} ${formatPkgName(pkg)} ${pkg.relativeDir} ${colors.yellow(pkg.version)}`);
160
160
  }
161
161
  console.log('\nSelect version strategy:');
162
162
  console.log(' 1) Patch');
@@ -228,7 +228,7 @@ export async function promptVersionStrategy(targetPkg, selection, opts) {
228
228
  if (selection.length === 1) {
229
229
  const proposed = bumpVersion(targetPkg.version, bumpType, preid);
230
230
  console.log(`\nTarget package: ${formatPkgName(targetPkg)} -> ${colors.green(proposed)}`);
231
- const others = selection.filter((pkg) => pkg.dirName !== targetPkg.dirName);
231
+ const others = selection.filter((pkg) => pkg.relativeDir !== targetPkg.relativeDir);
232
232
  if (others.length) {
233
233
  console.log('Other packages:');
234
234
  others.forEach((pkg) => {
package/dist/utils/log.js CHANGED
@@ -5,7 +5,7 @@ export const globalEmoji = '🚀';
5
5
  export const formatPkgName = (pkg) => {
6
6
  const primary = pkg.substitute;
7
7
  const displayName = pkg.name ?? pkg.dirName;
8
- const fileLabel = pkg.dirName;
8
+ const fileLabel = pkg.relativeDir || pkg.dirName;
9
9
  const colorizer = colors[pkg.color ?? defaultPackageColor];
10
10
  return `${colorizer(colors.bold(primary))} ${colors.dim(`(${displayName}) [${fileLabel}]`)}`;
11
11
  };
package/dist/workspace.js CHANGED
@@ -26,24 +26,39 @@ function dependencyPathsFromStatus(status) {
26
26
  .map(extractPathFromStatus)
27
27
  .filter((p) => Boolean(p && dependencyFiles.has(p.split('/').pop() ?? '')));
28
28
  }
29
+ function normalizePathForMatch(p) {
30
+ return p.replace(/\\/g, '/');
31
+ }
32
+ function findPackageForPath(p, targets) {
33
+ const normalized = normalizePathForMatch(p);
34
+ return targets.find((pkg) => {
35
+ const rel = normalizePathForMatch(pkg.relativeDir || '');
36
+ if (!rel || rel === '.')
37
+ return normalized === 'package.json';
38
+ const prefix = `${rel}/`;
39
+ return normalized === `${rel}/package.json` || normalized.startsWith(prefix);
40
+ });
41
+ }
29
42
  function formatPkgLabel(pkg) {
30
43
  return pkg.substitute ?? pkg.name ?? pkg.dirName;
31
44
  }
32
45
  function logDependencyChanges(paths, targets) {
33
46
  if (paths.length === 0)
34
47
  return;
35
- const byDir = new Map(targets.map((t) => [t.dirName, formatPkgLabel(t)]));
36
48
  logGlobal('Dependency file changes detected:', colors.cyan);
37
49
  paths.forEach((p) => {
38
- const parts = p.split('/');
39
- const file = parts[parts.length - 1];
40
- const pkgIndex = parts.indexOf('packages');
41
- if (pkgIndex !== -1 && parts[pkgIndex + 1]) {
42
- const dir = parts[pkgIndex + 1];
43
- const fileLabel = parts.slice(pkgIndex + 2).join('/') || 'package.json';
44
- console.log(` • ${colors.dim(`${byDir.get(dir) ?? dir} (${fileLabel})`)}`);
50
+ const normalized = normalizePathForMatch(p);
51
+ const matchedPkg = findPackageForPath(normalized, targets);
52
+ if (matchedPkg) {
53
+ const rel = normalizePathForMatch(matchedPkg.relativeDir);
54
+ const base = rel === '.' ? '' : `${rel}/`;
55
+ const fileLabel = normalized.startsWith(base)
56
+ ? normalized.slice(base.length)
57
+ : normalized;
58
+ console.log(` • ${colors.dim(`${formatPkgLabel(matchedPkg)} (${fileLabel || 'package.json'})`)}`);
45
59
  return;
46
60
  }
61
+ const file = normalized.split('/').pop() ?? '';
47
62
  if (file === 'pnpm-lock.yaml' ||
48
63
  file === 'package-lock.json' ||
49
64
  file === 'npm-shrinkwrap.json') {
@@ -100,16 +115,12 @@ function parseVersionChanges(path, label) {
100
115
  }));
101
116
  }
102
117
  function summarizeVersionChanges(paths, targets) {
103
- const byDir = new Map(targets.map((t) => [t.dirName, formatPkgLabel(t)]));
104
118
  const changes = [];
105
119
  paths
106
120
  .filter((p) => p.endsWith('package.json'))
107
121
  .forEach((p) => {
108
- const parts = p.split('/');
109
- const pkgIndex = parts.indexOf('packages');
110
- const label = pkgIndex !== -1 && parts[pkgIndex + 1]
111
- ? (byDir.get(parts[pkgIndex + 1]) ?? parts[pkgIndex + 1])
112
- : 'workspace';
122
+ const pkg = findPackageForPath(p, targets);
123
+ const label = pkg ? formatPkgLabel(pkg) : 'workspace';
113
124
  changes.push(...parseVersionChanges(p, label));
114
125
  });
115
126
  if (changes.length === 0)
@@ -133,15 +144,13 @@ function buildUpdateCommitMessage(paths, targets) {
133
144
  if (changeSummary)
134
145
  return `chore(deps): ${changeSummary}`;
135
146
  const labels = new Set();
136
- const byDir = new Map(targets.map((t) => [t.dirName, formatPkgLabel(t)]));
137
147
  paths.forEach((p) => {
138
- const parts = p.split('/');
139
- const pkgIndex = parts.indexOf('packages');
140
- if (pkgIndex !== -1 && parts[pkgIndex + 1]) {
141
- const dir = parts[pkgIndex + 1];
142
- labels.add(byDir.get(dir) ?? dir);
148
+ const matchedPkg = findPackageForPath(p, targets);
149
+ if (matchedPkg) {
150
+ labels.add(formatPkgLabel(matchedPkg));
143
151
  return;
144
152
  }
153
+ const parts = normalizePathForMatch(p).split('/');
145
154
  if (parts[parts.length - 1] === 'package.json') {
146
155
  labels.add('workspace deps');
147
156
  }
@@ -175,6 +184,13 @@ async function promptCommitMessage(proposed) {
175
184
  }
176
185
  return custom.trim();
177
186
  }
187
+ function packageFilterArg(pkg) {
188
+ if (pkg.name)
189
+ return pkg.name;
190
+ if (!pkg.relativeDir || pkg.relativeDir === '.')
191
+ return '.';
192
+ return `./${pkg.relativeDir}`;
193
+ }
178
194
  export async function runCleanInstall() {
179
195
  logGlobal('Cleaning workspace…', colors.cyan);
180
196
  await run('pnpm', ['run', 'clean']);
@@ -184,7 +200,7 @@ export async function runCleanInstall() {
184
200
  export async function updateDependencies(targets) {
185
201
  const preStatus = await collectGitStatus();
186
202
  if (targets.length === 1) {
187
- const filterArg = targets[0].name ?? `./packages/${targets[0].dirName}`;
203
+ const filterArg = packageFilterArg(targets[0]);
188
204
  logPkg(targets[0], `Updating dependencies…`);
189
205
  await run('pnpm', ['-r', '--filter', filterArg, 'update']);
190
206
  }
@@ -219,7 +235,7 @@ export async function typecheckAll() {
219
235
  await run('pnpm', ['typecheck']);
220
236
  }
221
237
  export async function typecheckSingle(pkg) {
222
- const filterArg = pkg.name ?? `./packages/${pkg.dirName}`;
238
+ const filterArg = packageFilterArg(pkg);
223
239
  logPkg(pkg, `Running typecheck…`);
224
240
  await run('pnpm', ['run', '--filter', filterArg, 'typecheck']);
225
241
  }
@@ -228,7 +244,7 @@ export async function buildAll() {
228
244
  await run('pnpm', ['build']);
229
245
  }
230
246
  export async function buildSingle(pkg) {
231
- const filterArg = pkg.name ?? `./packages/${pkg.dirName}`;
247
+ const filterArg = packageFilterArg(pkg);
232
248
  logPkg(pkg, `Running build…`);
233
249
  await run('pnpm', ['run', '--filter', filterArg, 'build']);
234
250
  }
@@ -242,5 +258,5 @@ export async function testAll() {
242
258
  }
243
259
  export async function testSingle(pkg) {
244
260
  logPkg(pkg, `Running tests…`);
245
- await run('pnpm', ['test', '--', `packages/${pkg.dirName}`]);
261
+ await run('pnpm', ['test', '--', pkg.relativeDir === '.' ? '.' : pkg.relativeDir]);
246
262
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emeryld/manager",
3
- "version": "0.4.6",
3
+ "version": "0.5.1",
4
4
  "description": "Interactive manager for pnpm monorepos (update/test/build/publish).",
5
5
  "license": "MIT",
6
6
  "type": "module",