@emeryld/manager 0.6.3 → 0.6.5

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,285 +1,9 @@
1
- import { spawn } from 'node:child_process';
2
- import { existsSync } from 'node:fs';
3
- import { createRequire } from 'node:module';
4
- import readline from 'node:readline/promises';
5
- import { stdin as input, stdout as output } from 'node:process';
6
- import { fileURLToPath } from 'node:url';
7
- import path from 'node:path';
8
- const __filename = fileURLToPath(import.meta.url);
9
- const managerRoot = path.resolve(path.dirname(__filename), '..');
10
- // Workspace root is wherever the CLI is executed.
11
- const rootDir = process.cwd();
12
- const managerRequire = createRequire(import.meta.url);
13
- let tsNodeLoaderPath;
14
- function getTsNodeLoaderPath() {
15
- if (!tsNodeLoaderPath) {
16
- tsNodeLoaderPath = managerRequire.resolve('ts-node/esm.mjs');
17
- }
18
- return tsNodeLoaderPath;
19
- }
20
- export function buildTsNodeRegisterImport(scriptPath) {
21
- const loader = getTsNodeLoaderPath();
22
- const code = [
23
- 'import { register } from "node:module";',
24
- 'import { pathToFileURL } from "node:url";',
25
- `register(${JSON.stringify(loader)}, pathToFileURL(${JSON.stringify(scriptPath)}));`,
26
- ].join(' ');
27
- return `data:text/javascript,${encodeURIComponent(code)}`;
28
- }
29
- const ansi = (code) => (text) => `\x1b[${code}m${text}\x1b[0m`;
30
- const colors = {
31
- cyan: ansi(36),
32
- green: ansi(32),
33
- yellow: ansi(33),
34
- magenta: ansi(35),
35
- dim: ansi(2),
36
- };
37
- function normalizeScripts(entries) {
38
- if (!Array.isArray(entries) || entries.length === 0) {
39
- throw new Error('runHelperCli requires at least one script definition.');
40
- }
41
- return entries.map((entry, index) => {
42
- if (!entry || typeof entry !== 'object') {
43
- throw new Error(`Script entry at index ${index} is not an object.`);
44
- }
45
- if (!entry.name || typeof entry.name !== 'string') {
46
- throw new Error(`Script entry at index ${index} is missing a "name".`);
47
- }
48
- const hasHandler = typeof entry.handler === 'function';
49
- const hasScript = typeof entry.script === 'string' && entry.script.length > 0;
50
- if (!hasHandler && !hasScript) {
51
- throw new Error(`Script "${entry.name}" requires either a "script" path or a "handler" function.`);
52
- }
53
- const absoluteScript = hasScript && path.isAbsolute(entry.script)
54
- ? entry.script
55
- : hasScript
56
- ? path.join(rootDir, entry.script)
57
- : undefined;
58
- return {
59
- ...entry,
60
- emoji: entry.emoji ?? '🔧',
61
- displayName: entry.name.trim(),
62
- absoluteScript,
63
- script: hasScript ? entry.script : undefined,
64
- handler: hasHandler ? entry.handler : undefined,
65
- metaLabel: entry.description ?? (hasScript ? entry.script : '[callback]'),
66
- };
67
- });
68
- }
69
- function findScriptEntry(entries, key) {
70
- if (!key)
71
- return undefined;
72
- const normalized = key.toLowerCase();
73
- return entries.find((entry) => {
74
- const nameMatch = entry.displayName.toLowerCase() === normalized;
75
- const aliasMatch = entry.displayName.toLowerCase().includes(normalized);
76
- const scriptMatch = entry.script
77
- ? entry.script.toLowerCase().includes(normalized)
78
- : false;
79
- return nameMatch || aliasMatch || scriptMatch;
80
- });
81
- }
82
- function printScriptList(entries, title) {
83
- const heading = title
84
- ? colors.magenta(title)
85
- : colors.magenta('Available scripts');
86
- console.log(heading);
87
- entries.forEach((entry, index) => {
88
- console.log(` ${colors.cyan(String(index + 1).padStart(2, ' '))} ${entry.emoji} ${entry.displayName} ${colors.dim(entry.metaLabel)}`);
89
- });
90
- }
91
- async function promptWithReadline(entries, title) {
92
- printScriptList(entries, title);
93
- const rl = readline.createInterface({ input, output });
94
- try {
95
- while (true) {
96
- const answer = (await rl.question(colors.cyan('\nSelect a script by number or name: '))).trim();
97
- if (!answer)
98
- continue;
99
- const numeric = Number.parseInt(answer, 10);
100
- if (!Number.isNaN(numeric) && numeric >= 1 && numeric <= entries.length) {
101
- return entries[numeric - 1];
102
- }
103
- const byName = findScriptEntry(entries, answer);
104
- if (byName)
105
- return byName;
106
- console.log(colors.yellow(`Could not find "${answer}". Try again.`));
107
- }
108
- }
109
- finally {
110
- rl.close();
111
- }
112
- }
113
- function formatInteractiveLines(entries, selectedIndex, title) {
114
- const heading = title
115
- ? colors.magenta(title)
116
- : colors.magenta('Available scripts');
117
- const lines = [heading];
118
- entries.forEach((entry, index) => {
119
- const isSelected = index === selectedIndex;
120
- const pointer = isSelected ? `${colors.green('➤')} ` : '';
121
- const numberLabel = colors.cyan(`${index + 1}`.padStart(2, ' '));
122
- const label = isSelected
123
- ? colors.green(entry.displayName)
124
- : entry.displayName;
125
- lines.push(`${pointer}${numberLabel}. ${entry.emoji} ${label} ${colors.dim(entry.metaLabel)}`);
126
- });
127
- lines.push('');
128
- lines.push(colors.dim('Use ↑/↓ (or j/k) to move, digits (1-9,0 for 10) to run instantly, Enter to confirm, Esc/Ctrl+C to exit.'));
129
- return lines;
130
- }
131
- function renderInteractiveList(lines, previousLineCount) {
132
- if (previousLineCount > 0) {
133
- process.stdout.write(`\x1b[${previousLineCount}A`);
134
- process.stdout.write('\x1b[0J');
135
- }
136
- lines.forEach((line) => console.log(line));
137
- return lines.length;
138
- }
139
- async function promptForScript(entries, title) {
140
- const supportsRawMode = typeof input.setRawMode === 'function' && input.isTTY;
141
- if (!supportsRawMode) {
142
- return promptWithReadline(entries, title);
143
- }
144
- const wasRaw = input.isRaw;
145
- if (!wasRaw) {
146
- input.setRawMode(true);
147
- input.resume();
148
- }
149
- process.stdout.write('\x1b[?25l');
150
- return new Promise((resolve) => {
151
- let selectedIndex = 0;
152
- let renderedLines = 0;
153
- const cleanup = () => {
154
- if (renderedLines > 0) {
155
- process.stdout.write(`\x1b[${renderedLines}A`);
156
- process.stdout.write('\x1b[0J');
157
- renderedLines = 0;
158
- }
159
- process.stdout.write('\x1b[?25h');
160
- if (!wasRaw) {
161
- input.setRawMode(false);
162
- input.pause();
163
- }
164
- input.removeListener('data', onData);
165
- };
166
- const commitSelection = (entry) => {
167
- cleanup();
168
- console.log();
169
- resolve(entry);
170
- };
171
- const render = () => {
172
- const lines = formatInteractiveLines(entries, selectedIndex, title);
173
- renderedLines = renderInteractiveList(lines, renderedLines);
174
- };
175
- const onData = (buffer) => {
176
- const isArrowUp = buffer.equals(Buffer.from([0x1b, 0x5b, 0x41]));
177
- const isArrowDown = buffer.equals(Buffer.from([0x1b, 0x5b, 0x42]));
178
- const isCtrlC = buffer.length === 1 && buffer[0] === 0x03;
179
- const isEnter = buffer.length === 1 && (buffer[0] === 0x0d || buffer[0] === 0x0a);
180
- const isEscape = buffer.length === 1 && buffer[0] === 0x1b;
181
- if (isCtrlC || isEscape) {
182
- cleanup();
183
- process.exit(1);
184
- }
185
- if (isArrowUp ||
186
- (buffer.length === 1 && (buffer[0] === 0x6b || buffer[0] === 0x4b))) {
187
- selectedIndex = (selectedIndex - 1 + entries.length) % entries.length;
188
- render();
189
- return;
190
- }
191
- if (isArrowDown ||
192
- (buffer.length === 1 && (buffer[0] === 0x6a || buffer[0] === 0x4a))) {
193
- selectedIndex = (selectedIndex + 1) % entries.length;
194
- render();
195
- return;
196
- }
197
- if (isEnter) {
198
- commitSelection(entries[selectedIndex]);
199
- return;
200
- }
201
- if (buffer.length === 1 && buffer[0] >= 0x30 && buffer[0] <= 0x39) {
202
- const numericValue = buffer[0] === 0x30 ? 10 : buffer[0] - 0x30;
203
- const selected = numericValue - 1;
204
- if (selected >= 0 && selected < entries.length) {
205
- commitSelection(entries[selected]);
206
- }
207
- else {
208
- process.stdout.write('\x07');
209
- }
210
- return;
211
- }
212
- if (buffer.length === 1 &&
213
- ((buffer[0] >= 0x41 && buffer[0] <= 0x5a) ||
214
- (buffer[0] >= 0x61 && buffer[0] <= 0x7a))) {
215
- const char = String.fromCharCode(buffer[0]).toLowerCase();
216
- const foundIndex = entries.findIndex((entry) => entry.displayName.toLowerCase().startsWith(char));
217
- if (foundIndex !== -1) {
218
- selectedIndex = foundIndex;
219
- render();
220
- }
221
- else {
222
- process.stdout.write('\x07');
223
- }
224
- }
225
- };
226
- input.on('data', onData);
227
- render();
228
- });
229
- }
230
- function runEntry(entry, forwardedArgs) {
231
- const detail = entry.script
232
- ? path.relative(rootDir, entry.absoluteScript ?? entry.script)
233
- : (entry.metaLabel ?? '[callback]');
234
- console.log(`${entry.emoji} ${colors.green(`Running "${entry.displayName}"`)} ${colors.dim(detail)}`);
235
- if (entry.handler) {
236
- return Promise.resolve(entry.handler({ args: forwardedArgs, entry, rootDir }));
237
- }
238
- if (!entry.absoluteScript) {
239
- throw new Error(`Script "${entry.displayName}" is missing a resolved path.`);
240
- }
241
- const scriptPath = entry.absoluteScript;
242
- const tsConfigPath = path.join(rootDir, 'tsconfig.base.json');
243
- const tsConfigFallback = path.join(rootDir, 'tsconfig.json');
244
- const bundledTsconfig = path.join(managerRoot, 'tsconfig.base.json');
245
- const projectPath = existsSync(tsConfigPath)
246
- ? tsConfigPath
247
- : existsSync(tsConfigFallback)
248
- ? tsConfigFallback
249
- : existsSync(bundledTsconfig)
250
- ? bundledTsconfig
251
- : undefined;
252
- const extension = path.extname(scriptPath).toLowerCase();
253
- const isTypeScript = extension === '.js' || extension === '.mts' || extension === '.cts';
254
- const command = process.execPath;
255
- const execArgs = isTypeScript
256
- ? [
257
- '--import',
258
- buildTsNodeRegisterImport(scriptPath),
259
- scriptPath,
260
- ...forwardedArgs,
261
- ]
262
- : [scriptPath, ...forwardedArgs];
263
- return new Promise((resolve, reject) => {
264
- const child = spawn(command, execArgs, {
265
- cwd: rootDir,
266
- stdio: 'inherit',
267
- env: isTypeScript
268
- ? {
269
- ...process.env,
270
- ...(projectPath ? { TS_NODE_PROJECT: projectPath } : {}),
271
- }
272
- : process.env,
273
- shell: process.platform === 'win32' && isTypeScript,
274
- });
275
- child.on('close', (code) => {
276
- if (code === 0)
277
- resolve();
278
- else
279
- reject(new Error(`Script "${entry.displayName}" exited with code ${code}`));
280
- });
281
- });
282
- }
1
+ import { colors } from './helper-cli/colors.js';
2
+ import { printScriptList } from './helper-cli/display.js';
3
+ import { promptForScript } from './helper-cli/prompts.js';
4
+ import { findScriptEntry, normalizeScripts } from './helper-cli/scripts.js';
5
+ import { runEntry } from './helper-cli/runner.js';
6
+ export { buildTsNodeRegisterImport } from './helper-cli/ts-node.js';
283
7
  export async function runHelperCli({ scripts, title = 'Helper CLI', argv = process.argv.slice(2), }) {
284
8
  const normalized = normalizeScripts(scripts);
285
9
  let args = Array.isArray(argv) ? [...argv] : [];
@@ -310,5 +34,7 @@ export async function runHelperCli({ scripts, title = 'Helper CLI', argv = proce
310
34
  console.log(colors.yellow(`Unknown script "${firstArg}". Falling back to interactive selectionâ€Ļ`));
311
35
  }
312
36
  const selection = await promptForScript(normalized, title);
313
- await runEntry(selection, []);
37
+ if (selection) {
38
+ await runEntry(selection, []);
39
+ }
314
40
  }
@@ -0,0 +1,178 @@
1
+ import { colors } from "../utils/log.js";
2
+ import { run } from "../utils/run.js";
3
+ export function collectDependencies(pkg) {
4
+ return new Set([
5
+ ...Object.keys(pkg.json?.dependencies ?? {}),
6
+ ...Object.keys(pkg.json?.devDependencies ?? {}),
7
+ ]);
8
+ }
9
+ const BASE_SCRIPT_KEYS = ['format', 'typecheck', 'dev', 'lint'];
10
+ function commandDescription(args) {
11
+ return `pnpm ${args.join(' ')}`;
12
+ }
13
+ function resolveRunScript(scripts, candidates) {
14
+ for (const name of candidates) {
15
+ if (!scripts[name])
16
+ continue;
17
+ const cmd = commandDescription(['run', name]);
18
+ const scriptDetail = scripts[name]?.trim();
19
+ const description = scriptDetail ? `${cmd} (${scriptDetail})` : cmd;
20
+ return {
21
+ label: name,
22
+ description,
23
+ args: ['run', name],
24
+ available: true,
25
+ };
26
+ }
27
+ return undefined;
28
+ }
29
+ function resolveFormatScript(pkg, scripts, dependencies) {
30
+ const direct = resolveRunScript(scripts, ['format', 'fmt']);
31
+ if (direct)
32
+ return direct;
33
+ if (dependencies.has('prettier')) {
34
+ const args = ['exec', 'prettier', '--check', '.'];
35
+ return {
36
+ label: 'prettier --check',
37
+ description: commandDescription(args),
38
+ args,
39
+ available: true,
40
+ };
41
+ }
42
+ return {
43
+ label: 'format',
44
+ description: `No format/fmt script found for ${pkg.name}`,
45
+ available: false,
46
+ };
47
+ }
48
+ function resolveTypecheckScript(pkg, scripts, dependencies) {
49
+ const direct = resolveRunScript(scripts, ['typecheck', 'check']);
50
+ if (direct)
51
+ return direct;
52
+ if (dependencies.has('typescript')) {
53
+ const args = ['exec', 'tsc', '--noEmit', '--pretty', 'false'];
54
+ return {
55
+ label: 'tsc --noEmit',
56
+ description: commandDescription(args),
57
+ args,
58
+ available: true,
59
+ };
60
+ }
61
+ return {
62
+ label: 'typecheck',
63
+ description: `No typecheck/check script found for ${pkg.name}`,
64
+ available: false,
65
+ };
66
+ }
67
+ function resolveLintScript(pkg, scripts, dependencies) {
68
+ const direct = resolveRunScript(scripts, ['lint']);
69
+ if (direct)
70
+ return direct;
71
+ if (dependencies.has('eslint')) {
72
+ const args = ['exec', 'eslint', '.', '--ext', '.ts,.tsx,.js,.jsx'];
73
+ return {
74
+ label: 'eslint .',
75
+ description: commandDescription(args),
76
+ args,
77
+ available: true,
78
+ };
79
+ }
80
+ return {
81
+ label: 'lint',
82
+ description: `No lint script found for ${pkg.name}`,
83
+ available: false,
84
+ };
85
+ }
86
+ function resolveDevScript(pkg, scripts, dependencies, kind) {
87
+ const devCandidates = kind === 'frontend'
88
+ ? ['dev', 'start', 'serve']
89
+ : kind === 'backend'
90
+ ? ['dev', 'start', 'watch', 'serve', 'start:dev']
91
+ : ['dev', 'start'];
92
+ const direct = resolveRunScript(scripts, devCandidates);
93
+ if (direct)
94
+ return direct;
95
+ if (dependencies.has('next')) {
96
+ const args = ['exec', 'next', 'dev'];
97
+ return {
98
+ label: 'next dev',
99
+ description: commandDescription(args),
100
+ args,
101
+ available: true,
102
+ };
103
+ }
104
+ if (dependencies.has('vite')) {
105
+ const args = ['exec', 'vite', 'dev'];
106
+ return {
107
+ label: 'vite dev',
108
+ description: commandDescription(args),
109
+ args,
110
+ available: true,
111
+ };
112
+ }
113
+ if (dependencies.has('expo')) {
114
+ const args = ['exec', 'expo', 'start'];
115
+ return {
116
+ label: 'expo start',
117
+ description: commandDescription(args),
118
+ args,
119
+ available: true,
120
+ };
121
+ }
122
+ return {
123
+ label: 'dev',
124
+ description: `No dev/start script found for ${pkg.name}`,
125
+ available: false,
126
+ };
127
+ }
128
+ function resolveBaseScript(pkg, key, scripts, dependencies, kind) {
129
+ switch (key) {
130
+ case 'format':
131
+ return resolveFormatScript(pkg, scripts, dependencies);
132
+ case 'typecheck':
133
+ return resolveTypecheckScript(pkg, scripts, dependencies);
134
+ case 'lint':
135
+ return resolveLintScript(pkg, scripts, dependencies);
136
+ case 'dev':
137
+ default:
138
+ return resolveDevScript(pkg, scripts, dependencies, kind);
139
+ }
140
+ }
141
+ export function makeBaseScriptEntries(pkg) {
142
+ const scripts = pkg.json?.scripts ?? {};
143
+ const dependencies = collectDependencies(pkg);
144
+ const marker = getPackageMarker(pkg, dependencies);
145
+ return BASE_SCRIPT_KEYS.map((key) => {
146
+ const resolution = resolveBaseScript(pkg, key, scripts, dependencies, marker.kind);
147
+ const description = resolution.available
148
+ ? resolution.description
149
+ : `No ${key} command detected`;
150
+ return {
151
+ name: key,
152
+ emoji: 'âšĄī¸',
153
+ description,
154
+ handler: async () => {
155
+ if (!resolution.available || !resolution.args) {
156
+ console.log(colors.yellow(`No ${key} command found for ${pkg.name}.`));
157
+ return;
158
+ }
159
+ await run('pnpm', resolution.args, { cwd: pkg.path });
160
+ },
161
+ };
162
+ });
163
+ }
164
+ export function getPackageMarker(pkg, dependencies = collectDependencies(pkg)) {
165
+ const hasFrontend = ['react', 'next', 'expo', 'react-native', 'vite'].some((dep) => dependencies.has(dep));
166
+ const hasBackend = ['express', 'fastify', 'hono', 'koa', '@nestjs/core'].some((dep) => dependencies.has(dep));
167
+ const pkgType = pkg.json?.type?.toLowerCase();
168
+ if (hasFrontend) {
169
+ return { label: 'frontend', colorize: colors.magenta, kind: 'frontend' };
170
+ }
171
+ if (hasBackend) {
172
+ return { label: 'backend', colorize: colors.green, kind: 'backend' };
173
+ }
174
+ if (pkgType === 'module') {
175
+ return { label: 'esm', colorize: colors.yellow, kind: 'library' };
176
+ }
177
+ return { label: 'node', colorize: colors.cyan, kind: 'library' };
178
+ }
package/dist/menu.js CHANGED
@@ -1,13 +1,14 @@
1
1
  // src/menu.js
2
2
  import path from 'node:path';
3
3
  import { colors, globalEmoji } from './utils/log.js';
4
- import { updateDependencies, testAll, testSingle, buildAll, buildSingle, } from './workspace.js';
4
+ import { updateDependencies, testAll, testSingle, buildAll, buildSingle, cleanPackages, rebuildPackages, } from './workspace.js';
5
5
  import { releaseMultiple, releaseSingle } from './release.js';
6
6
  import { getOrderedPackages } from './packages.js';
7
7
  import { runHelperCli } from './helper-cli.js';
8
8
  import { ensureWorkingTreeCommitted } from './preflight.js';
9
9
  import { openDockerHelper } from './docker.js';
10
10
  import { run } from './utils/run.js';
11
+ import { makeBaseScriptEntries, getPackageMarker } from './menu/script-helpers.js';
11
12
  function makeManagerStepEntries(targets, packages, state, options) {
12
13
  const includeBack = options?.includeBack ?? true;
13
14
  return [
@@ -20,6 +21,24 @@ function makeManagerStepEntries(targets, packages, state, options) {
20
21
  state.lastStep = 'update';
21
22
  },
22
23
  },
24
+ {
25
+ name: 'clean',
26
+ emoji: '🧹',
27
+ description: 'Remove node_modules, dist, and caches',
28
+ handler: async () => {
29
+ await cleanPackages(targets);
30
+ state.lastStep = 'clean';
31
+ },
32
+ },
33
+ {
34
+ name: 'rebuild',
35
+ emoji: '🔁',
36
+ description: 'Clean then build',
37
+ handler: async () => {
38
+ await rebuildPackages(targets);
39
+ state.lastStep = 'rebuild';
40
+ },
41
+ },
23
42
  {
24
43
  name: 'test',
25
44
  emoji: 'đŸ§Ē',
@@ -93,25 +112,32 @@ function makeManagerStepEntries(targets, packages, state, options) {
93
112
  : []),
94
113
  ];
95
114
  }
96
- function makePackageScriptEntries(pkg) {
97
- const scripts = pkg.json?.scripts ?? {};
98
- const entries = [];
115
+ function makeDockerEntry(pkg) {
99
116
  if (pkg.dockerfilePath) {
100
117
  const dockerLabel = path
101
118
  .relative(process.cwd(), pkg.dockerfilePath)
102
119
  .replace(/\\/g, '/');
103
- entries.push({
120
+ return {
104
121
  name: `Dockerfile (${dockerLabel})`,
105
122
  emoji: 'đŸŗ',
106
123
  description: 'manager -> opens the docker cli',
107
124
  handler: async () => {
108
125
  await openDockerHelper(pkg);
109
126
  },
110
- });
127
+ };
111
128
  }
129
+ return undefined;
130
+ }
131
+ function makePackageScriptEntries(pkg) {
132
+ const scripts = pkg.json?.scripts ?? {};
133
+ const baseEntries = makeBaseScriptEntries(pkg);
134
+ const baseNames = new Set(baseEntries.map((entry) => entry.name));
135
+ const entries = [];
112
136
  Object.entries(scripts)
113
137
  .sort(([a], [b]) => a.localeCompare(b))
114
138
  .forEach(([name, command]) => {
139
+ if (baseNames.has(name))
140
+ return;
115
141
  entries.push({
116
142
  name,
117
143
  emoji: 'â–ļī¸',
@@ -121,23 +147,31 @@ function makePackageScriptEntries(pkg) {
121
147
  },
122
148
  });
123
149
  });
124
- return entries;
150
+ return [...baseEntries, ...entries];
125
151
  }
126
152
  export function buildPackageSelectionMenu(packages, onStepComplete) {
127
153
  const ordered = getOrderedPackages(packages);
128
- const entries = ordered.map((pkg) => ({
129
- name: pkg.name ?? pkg.substitute ?? pkg.dirName,
130
- emoji: colors[pkg.color]('●'),
131
- description: pkg.relativeDir ?? pkg.dirName,
132
- handler: async () => {
133
- const step = await runStepLoop([pkg], packages);
134
- onStepComplete?.(step);
135
- },
136
- }));
154
+ const entries = ordered.map((pkg) => {
155
+ const marker = getPackageMarker(pkg);
156
+ const pkgColor = pkg.color ?? 'cyan';
157
+ const descriptionMeta = pkg.relativeDir ?? pkg.dirName;
158
+ const markerHint = marker.label ? ` ¡ ${marker.label}` : '';
159
+ return {
160
+ name: pkg.name ?? pkg.substitute ?? pkg.dirName,
161
+ emoji: marker.colorize('●'),
162
+ description: `${descriptionMeta}${colors.dim(markerHint)}`,
163
+ color: pkgColor,
164
+ handler: async () => {
165
+ const step = await runStepLoop([pkg], packages);
166
+ onStepComplete?.(step);
167
+ },
168
+ };
169
+ });
137
170
  if (ordered.length === 0)
138
171
  return entries;
139
172
  entries.push({
140
173
  name: 'All packages',
174
+ color: 'gray',
141
175
  emoji: globalEmoji,
142
176
  description: 'Select all packages',
143
177
  handler: async () => {
@@ -154,10 +188,12 @@ export async function runStepLoop(targets, packages) {
154
188
  // Single package: show combined menu (manager actions + package.json scripts)
155
189
  if (targets.length === 1) {
156
190
  const pkg = targets[0];
191
+ const dockerEntry = makeDockerEntry(pkg);
157
192
  // eslint-disable-next-line no-constant-condition
158
193
  while (true) {
159
194
  const scriptEntries = makePackageScriptEntries(pkg);
160
195
  const entries = [
196
+ ...(dockerEntry ? [dockerEntry] : []),
161
197
  {
162
198
  name: 'manager actions',
163
199
  emoji: globalEmoji,
@@ -170,9 +206,8 @@ export async function runStepLoop(targets, packages) {
170
206
  {
171
207
  name: 'back',
172
208
  emoji: 'â†Šī¸',
173
- description: 'Return to package scripts',
209
+ description: 'Return to package menu',
174
210
  handler: () => {
175
- // no state change; just exit to the package scripts menu
176
211
  state.lastStep = undefined;
177
212
  },
178
213
  },
@@ -184,7 +219,31 @@ export async function runStepLoop(targets, packages) {
184
219
  });
185
220
  },
186
221
  },
187
- ...scriptEntries,
222
+ {
223
+ name: 'package scripts',
224
+ emoji: '📜',
225
+ description: 'Run package.json scripts',
226
+ handler: async () => {
227
+ if (scriptEntries.length === 0) {
228
+ console.log(colors.yellow(`No package.json scripts found for ${pkg.name}.`));
229
+ return;
230
+ }
231
+ const scriptsMenu = [
232
+ ...scriptEntries,
233
+ {
234
+ name: 'back',
235
+ emoji: 'â†Šī¸',
236
+ description: 'Return to package menu',
237
+ handler: () => { },
238
+ },
239
+ ];
240
+ await runHelperCli({
241
+ title: `${pkg.name} scripts`,
242
+ scripts: scriptsMenu,
243
+ argv: [],
244
+ });
245
+ },
246
+ },
188
247
  {
189
248
  name: 'back',
190
249
  emoji: 'â†Šī¸',