@emeryld/manager 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from 'node:child_process'
3
+ import path from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+
6
+ const __filename = fileURLToPath(import.meta.url)
7
+ const __dirname = path.dirname(__filename)
8
+ const packageRoot = path.resolve(__dirname, '..')
9
+ const tsconfigPath = path.join(packageRoot, 'tsconfig.base.json')
10
+ const entryPoint = path.join(packageRoot, 'src', 'publish.ts')
11
+
12
+ const nodeArgs = ['--loader', 'ts-node/esm', entryPoint, ...process.argv.slice(2)]
13
+ const execOptions = {
14
+ env: { ...process.env, TS_NODE_PROJECT: tsconfigPath },
15
+ stdio: 'inherit',
16
+ }
17
+
18
+ const child = spawnSync(process.execPath, nodeArgs, execOptions)
19
+ if (child.error) throw child.error
20
+ if (child.signal) {
21
+ process.kill(process.pid, child.signal)
22
+ } else {
23
+ process.exit(child.status ?? 0)
24
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@emeryld/manager",
3
+ "version": "0.1.0",
4
+ "description": "Interactive manager for pnpm monorepos (update/test/build/publish).",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "manager-cli": "bin/manager-cli.js"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "bin",
13
+ "tsconfig.base.json",
14
+ "tsconfig.json"
15
+ ],
16
+ "keywords": [
17
+ "pnpm",
18
+ "monorepo",
19
+ "manager",
20
+ "release"
21
+ ],
22
+ "engines": {
23
+ "node": ">=18"
24
+ },
25
+ "dependencies": {
26
+ "semver": "^7.7.3",
27
+ "ts-node": "^10.9.1",
28
+ "typescript": "^5.3.0"
29
+ },
30
+ "ts-node": {
31
+ "esm": true,
32
+ "project": "tsconfig.base.json",
33
+ "transpileOnly": true,
34
+ "swc": false
35
+ },
36
+ "scripts": {
37
+ "build": "tsc -p tsconfig.base.json",
38
+ "typecheck": "tsc -p tsconfig.base.json --noEmit"
39
+ }
40
+ }
package/src/git.ts ADDED
@@ -0,0 +1,74 @@
1
+ // src/git.ts
2
+ import { spawn } from 'node:child_process'
3
+ import { run } from './utils/run.ts'
4
+ import { logGlobal, colors } from './utils/log.ts'
5
+ import { rootDir } from './utils/run.ts'
6
+
7
+ export async function collectGitStatus(): Promise<string[]> {
8
+ return new Promise<string[]>((resolve, reject) => {
9
+ const child = spawn('git', ['status', '--porcelain'], {
10
+ cwd: rootDir,
11
+ stdio: ['ignore', 'pipe', 'inherit'],
12
+ })
13
+ const lines: string[] = []
14
+ child.stdout.on('data', (chunk) => {
15
+ lines.push(
16
+ ...chunk
17
+ .toString()
18
+ .split('\n')
19
+ // Preserve leading status markers; strip only trailing whitespace/newlines
20
+ .map((line: string) => line.replace(/\s+$/, ''))
21
+ .filter(Boolean),
22
+ )
23
+ })
24
+ child.on('error', reject)
25
+ child.on('close', (code) => {
26
+ if (code !== 0) return reject(new Error('Failed to read git status.'))
27
+ resolve(lines)
28
+ })
29
+ })
30
+ }
31
+
32
+ export async function gitAdd(paths: string[]) {
33
+ await run('git', ['add', ...paths])
34
+ }
35
+
36
+ export async function gitCommit(message: string) {
37
+ await run('git', ['commit', '-m', message])
38
+ }
39
+
40
+ export async function hasStagedChanges() {
41
+ return new Promise<boolean>((resolve, reject) => {
42
+ const child = spawn('git', ['diff', '--cached', '--quiet'], {
43
+ cwd: rootDir,
44
+ stdio: 'ignore',
45
+ })
46
+ child.on('close', (code) => {
47
+ if (code === 0) resolve(false)
48
+ else if (code === 1) resolve(true)
49
+ else reject(new Error('git diff --cached --quiet failed'))
50
+ })
51
+ })
52
+ }
53
+
54
+ export async function stageCommitPush(
55
+ changedPaths: string[],
56
+ commitMessage: string,
57
+ ) {
58
+ if (!changedPaths.length) {
59
+ logGlobal('No files changed; skipping commit.', colors.dim)
60
+ return
61
+ }
62
+ const unique = [...new Set(changedPaths)]
63
+ logGlobal('Staging files…', colors.cyan)
64
+ await gitAdd(unique)
65
+ const staged = await hasStagedChanges()
66
+ if (!staged) {
67
+ logGlobal('Nothing to commit; skipping commit/push.', colors.dim)
68
+ return
69
+ }
70
+ logGlobal('Creating commit…', colors.cyan)
71
+ await gitCommit(commitMessage)
72
+ logGlobal('Pushing to origin…', colors.cyan)
73
+ await run('git', ['push'])
74
+ }
@@ -0,0 +1,379 @@
1
+ import { spawn } from 'node:child_process'
2
+ import readline from 'node:readline/promises'
3
+ import { stdin as input, stdout as output } from 'node:process'
4
+ import { fileURLToPath } from 'node:url'
5
+ import path from 'node:path'
6
+
7
+ const __filename = fileURLToPath(import.meta.url)
8
+ const rootDir = path.resolve(path.dirname(__filename), '..')
9
+
10
+ const ansi = (code: number) => (text: string) => `\x1b[${code}m${text}\x1b[0m`
11
+ const colors = {
12
+ cyan: ansi(36),
13
+ green: ansi(32),
14
+ yellow: ansi(33),
15
+ magenta: ansi(35),
16
+ dim: ansi(2),
17
+ }
18
+
19
+ export interface HelperScriptContext {
20
+ args: string[]
21
+ entry: NormalizedScriptEntry
22
+ rootDir: string
23
+ }
24
+
25
+ export type HelperScriptHandler = (
26
+ context: HelperScriptContext,
27
+ ) => Promise<void> | void
28
+
29
+ export interface HelperScriptEntry {
30
+ name: string
31
+ emoji?: string
32
+ script?: string
33
+ description?: string
34
+ handler?: HelperScriptHandler
35
+ }
36
+
37
+ interface NormalizedScriptEntry extends HelperScriptEntry {
38
+ displayName: string
39
+ emoji: string
40
+ absoluteScript?: string
41
+ metaLabel: string
42
+ handler?: HelperScriptHandler
43
+ }
44
+
45
+ type ResolvedScriptEntry = NormalizedScriptEntry &
46
+ Required<Pick<NormalizedScriptEntry, 'metaLabel'>>
47
+
48
+ function normalizeScripts(entries: HelperScriptEntry[]): ResolvedScriptEntry[] {
49
+ if (!Array.isArray(entries) || entries.length === 0) {
50
+ throw new Error('runHelperCli requires at least one script definition.')
51
+ }
52
+
53
+ return entries.map((entry, index) => {
54
+ if (!entry || typeof entry !== 'object') {
55
+ throw new Error(`Script entry at index ${index} is not an object.`)
56
+ }
57
+ if (!entry.name || typeof entry.name !== 'string') {
58
+ throw new Error(`Script entry at index ${index} is missing a "name".`)
59
+ }
60
+ const hasHandler = typeof entry.handler === 'function'
61
+ const hasScript =
62
+ typeof entry.script === 'string' && entry.script.length > 0
63
+ if (!hasHandler && !hasScript) {
64
+ throw new Error(
65
+ `Script "${entry.name}" requires either a "script" path or a "handler" function.`,
66
+ )
67
+ }
68
+ const absoluteScript =
69
+ hasScript && path.isAbsolute(entry.script!)
70
+ ? entry.script
71
+ : hasScript
72
+ ? path.join(rootDir, entry.script!)
73
+ : undefined
74
+ return {
75
+ ...entry,
76
+ emoji: entry.emoji ?? '🔧',
77
+ displayName: entry.name.trim(),
78
+ absoluteScript,
79
+ script: hasScript ? entry.script : undefined,
80
+ handler: hasHandler ? entry.handler : undefined,
81
+ metaLabel:
82
+ entry.description ?? (hasScript ? entry.script! : '[callback]'),
83
+ }
84
+ })
85
+ }
86
+
87
+ function findScriptEntry(
88
+ entries: ResolvedScriptEntry[],
89
+ key?: string,
90
+ ): ResolvedScriptEntry | undefined {
91
+ if (!key) return undefined
92
+ const normalized = key.toLowerCase()
93
+ return entries.find((entry) => {
94
+ const nameMatch = entry.displayName.toLowerCase() === normalized
95
+ const aliasMatch = entry.displayName.toLowerCase().includes(normalized)
96
+ const scriptMatch = entry.script
97
+ ? entry.script.toLowerCase().includes(normalized)
98
+ : false
99
+ return nameMatch || aliasMatch || scriptMatch
100
+ })
101
+ }
102
+
103
+ function printScriptList(entries: ResolvedScriptEntry[], title?: string) {
104
+ const heading = title
105
+ ? colors.magenta(title)
106
+ : colors.magenta('Available scripts')
107
+ console.log(heading)
108
+ entries.forEach((entry, index) => {
109
+ console.log(
110
+ ` ${colors.cyan(String(index + 1).padStart(2, ' '))} ${entry.emoji} ${entry.displayName} ${colors.dim(entry.metaLabel)}`,
111
+ )
112
+ })
113
+ }
114
+
115
+ async function promptWithReadline(
116
+ entries: ResolvedScriptEntry[],
117
+ title?: string,
118
+ ) {
119
+ printScriptList(entries, title)
120
+ const rl = readline.createInterface({ input, output })
121
+ try {
122
+ while (true) {
123
+ const answer = (
124
+ await rl.question(colors.cyan('\nSelect a script by number or name: '))
125
+ ).trim()
126
+ if (!answer) continue
127
+ const numeric = Number.parseInt(answer, 10)
128
+ if (!Number.isNaN(numeric) && numeric >= 1 && numeric <= entries.length) {
129
+ return entries[numeric - 1]
130
+ }
131
+ const byName = findScriptEntry(entries, answer)
132
+ if (byName) return byName
133
+ console.log(colors.yellow(`Could not find "${answer}". Try again.`))
134
+ }
135
+ } finally {
136
+ rl.close()
137
+ }
138
+ }
139
+
140
+ function formatInteractiveLines(
141
+ entries: ResolvedScriptEntry[],
142
+ selectedIndex: number,
143
+ title?: string,
144
+ ) {
145
+ const heading = title
146
+ ? colors.magenta(title)
147
+ : colors.magenta('Available scripts')
148
+ const lines = [heading]
149
+ entries.forEach((entry, index) => {
150
+ const isSelected = index === selectedIndex
151
+ const pointer = isSelected ? `${colors.green('➤')} ` : ''
152
+ const numberLabel = colors.cyan(`${index + 1}`.padStart(2, ' '))
153
+ const label = isSelected
154
+ ? colors.green(entry.displayName)
155
+ : entry.displayName
156
+ lines.push(
157
+ `${pointer}${numberLabel}. ${entry.emoji} ${label} ${colors.dim(entry.metaLabel)}`,
158
+ )
159
+ })
160
+ lines.push('')
161
+ lines.push(
162
+ colors.dim(
163
+ 'Use ↑/↓ (or j/k) to move, digits (1-9,0 for 10) to run instantly, Enter to confirm, Esc/Ctrl+C to exit.',
164
+ ),
165
+ )
166
+ return lines
167
+ }
168
+
169
+ function renderInteractiveList(lines: string[], previousLineCount: number) {
170
+ if (previousLineCount > 0) {
171
+ process.stdout.write(`\x1b[${previousLineCount}A`)
172
+ process.stdout.write('\x1b[0J')
173
+ }
174
+ lines.forEach((line) => console.log(line))
175
+ return lines.length
176
+ }
177
+
178
+ async function promptForScript(entries: ResolvedScriptEntry[], title?: string) {
179
+ const supportsRawMode = typeof input.setRawMode === 'function' && input.isTTY
180
+ if (!supportsRawMode) {
181
+ return promptWithReadline(entries, title)
182
+ }
183
+ const wasRaw = input.isRaw
184
+ if (!wasRaw) {
185
+ input.setRawMode(true)
186
+ input.resume()
187
+ }
188
+ process.stdout.write('\x1b[?25l')
189
+
190
+ return new Promise<ResolvedScriptEntry>((resolve) => {
191
+ let selectedIndex = 0
192
+ let renderedLines = 0
193
+
194
+ const cleanup = () => {
195
+ if (renderedLines > 0) {
196
+ process.stdout.write(`\x1b[${renderedLines}A`)
197
+ process.stdout.write('\x1b[0J')
198
+ renderedLines = 0
199
+ }
200
+ process.stdout.write('\x1b[?25h')
201
+ if (!wasRaw) {
202
+ input.setRawMode(false)
203
+ input.pause()
204
+ }
205
+ input.removeListener('data', onData)
206
+ }
207
+
208
+ const commitSelection = (entry: ResolvedScriptEntry) => {
209
+ cleanup()
210
+ console.log()
211
+ resolve(entry)
212
+ }
213
+
214
+ const render = () => {
215
+ const lines = formatInteractiveLines(entries, selectedIndex, title)
216
+ renderedLines = renderInteractiveList(lines, renderedLines)
217
+ }
218
+
219
+ const onData = (buffer: Buffer) => {
220
+ const isArrowUp = buffer.equals(Buffer.from([0x1b, 0x5b, 0x41]))
221
+ const isArrowDown = buffer.equals(Buffer.from([0x1b, 0x5b, 0x42]))
222
+ const isCtrlC = buffer.length === 1 && buffer[0] === 0x03
223
+ const isEnter =
224
+ buffer.length === 1 && (buffer[0] === 0x0d || buffer[0] === 0x0a)
225
+ const isEscape = buffer.length === 1 && buffer[0] === 0x1b
226
+
227
+ if (isCtrlC || isEscape) {
228
+ cleanup()
229
+ process.exit(1)
230
+ }
231
+
232
+ if (
233
+ isArrowUp ||
234
+ (buffer.length === 1 && (buffer[0] === 0x6b || buffer[0] === 0x4b))
235
+ ) {
236
+ selectedIndex = (selectedIndex - 1 + entries.length) % entries.length
237
+ render()
238
+ return
239
+ }
240
+ if (
241
+ isArrowDown ||
242
+ (buffer.length === 1 && (buffer[0] === 0x6a || buffer[0] === 0x4a))
243
+ ) {
244
+ selectedIndex = (selectedIndex + 1) % entries.length
245
+ render()
246
+ return
247
+ }
248
+
249
+ if (isEnter) {
250
+ commitSelection(entries[selectedIndex])
251
+ return
252
+ }
253
+
254
+ if (buffer.length === 1 && buffer[0] >= 0x30 && buffer[0] <= 0x39) {
255
+ const numericValue = buffer[0] === 0x30 ? 10 : buffer[0] - 0x30
256
+ const selected = numericValue - 1
257
+ if (selected >= 0 && selected < entries.length) {
258
+ commitSelection(entries[selected])
259
+ } else {
260
+ process.stdout.write('\x07')
261
+ }
262
+ return
263
+ }
264
+
265
+ if (
266
+ buffer.length === 1 &&
267
+ ((buffer[0] >= 0x41 && buffer[0] <= 0x5a) ||
268
+ (buffer[0] >= 0x61 && buffer[0] <= 0x7a))
269
+ ) {
270
+ const char = String.fromCharCode(buffer[0]).toLowerCase()
271
+ const foundIndex = entries.findIndex((entry) =>
272
+ entry.displayName.toLowerCase().startsWith(char),
273
+ )
274
+ if (foundIndex !== -1) {
275
+ selectedIndex = foundIndex
276
+ render()
277
+ } else {
278
+ process.stdout.write('\x07')
279
+ }
280
+ }
281
+ }
282
+
283
+ input.on('data', onData)
284
+ render()
285
+ })
286
+ }
287
+
288
+ function runEntry(entry: ResolvedScriptEntry, forwardedArgs: string[]) {
289
+ const detail = entry.script
290
+ ? path.relative(rootDir, entry.absoluteScript ?? entry.script)
291
+ : (entry.metaLabel ?? '[callback]')
292
+ console.log(
293
+ `${entry.emoji} ${colors.green(`Running "${entry.displayName}"`)} ${colors.dim(detail)}`,
294
+ )
295
+ if (entry.handler) {
296
+ return Promise.resolve(
297
+ entry.handler({ args: forwardedArgs, entry, rootDir }),
298
+ )
299
+ }
300
+ if (!entry.absoluteScript) {
301
+ throw new Error(`Script "${entry.displayName}" is missing a resolved path.`)
302
+ }
303
+ const scriptPath = entry.absoluteScript
304
+ const tsConfigPath = path.join(rootDir, 'tsconfig.base.json')
305
+ const extension = path.extname(scriptPath).toLowerCase()
306
+ const isTypeScript = extension === '.ts' || extension === '.mts' || extension === '.cts'
307
+ const command = process.execPath
308
+ const execArgs = isTypeScript
309
+ ? ['--loader', 'ts-node/esm', scriptPath, ...forwardedArgs]
310
+ : [scriptPath, ...forwardedArgs]
311
+ return new Promise<void>((resolve, reject) => {
312
+ const child = spawn(command, execArgs, {
313
+ cwd: rootDir,
314
+ stdio: 'inherit',
315
+ env: isTypeScript
316
+ ? { ...process.env, TS_NODE_PROJECT: tsConfigPath }
317
+ : process.env,
318
+ shell: process.platform === 'win32' && isTypeScript,
319
+ })
320
+ child.on('close', (code) => {
321
+ if (code === 0) resolve()
322
+ else
323
+ reject(
324
+ new Error(`Script "${entry.displayName}" exited with code ${code}`),
325
+ )
326
+ })
327
+ })
328
+ }
329
+
330
+ export interface HelperCliOptions {
331
+ scripts: HelperScriptEntry[]
332
+ title?: string
333
+ argv?: string[]
334
+ }
335
+
336
+ export async function runHelperCli({
337
+ scripts,
338
+ title = 'Helper CLI',
339
+ argv = process.argv.slice(2),
340
+ }: HelperCliOptions) {
341
+ const normalized = normalizeScripts(scripts)
342
+ let args = Array.isArray(argv) ? [...argv] : []
343
+ const separatorIndex = args.indexOf('--')
344
+ if (separatorIndex !== -1) {
345
+ args = [...args.slice(0, separatorIndex), ...args.slice(separatorIndex + 1)]
346
+ }
347
+ const [firstArg, ...restArgs] = args
348
+
349
+ if (firstArg === '--list' || firstArg === '-l') {
350
+ printScriptList(normalized, title)
351
+ return
352
+ }
353
+
354
+ if (firstArg === '--help' || firstArg === '-h') {
355
+ console.log(colors.magenta('Usage:'))
356
+ console.log(' pnpm run <cli> [script] [...args]')
357
+ console.log('\nFlags:')
358
+ console.log(' --list Show available scripts')
359
+ console.log(' --help Show this information')
360
+ return
361
+ }
362
+
363
+ const argLooksLikeScript = firstArg && !firstArg.startsWith('-')
364
+ if (argLooksLikeScript) {
365
+ const requested = findScriptEntry(normalized, firstArg)
366
+ if (requested) {
367
+ await runEntry(requested, restArgs)
368
+ return
369
+ }
370
+ console.log(
371
+ colors.yellow(
372
+ `Unknown script "${firstArg}". Falling back to interactive selection…`,
373
+ ),
374
+ )
375
+ }
376
+
377
+ const selection = await promptForScript(normalized, title)
378
+ await runEntry(selection, [])
379
+ }
package/src/menu.ts ADDED
@@ -0,0 +1,142 @@
1
+ // src/menu.ts
2
+ import type { HelperScriptEntry } from './helper-cli.ts'
3
+ import type { LoadedPackage } from './utils/log.ts'
4
+ import { colors, globalEmoji } from './utils/log.ts'
5
+ import {
6
+ updateDependencies,
7
+ testAll,
8
+ testSingle,
9
+ buildAll,
10
+ buildSingle,
11
+ } from './workspace.ts'
12
+
13
+ import { releaseMultiple, releaseSingle } from './release.ts'
14
+ import { getOrderedPackages } from './packages.ts'
15
+ import { runHelperCli } from './helper-cli.ts'
16
+ import { ensureWorkingTreeCommitted } from './preflight.ts'
17
+
18
+ export type StepKey = 'update' | 'test' | 'build' | 'publish' | 'full' | 'back'
19
+
20
+ export function makeStepEntries(
21
+ targets: LoadedPackage[],
22
+ packages: LoadedPackage[],
23
+ state: { lastStep?: StepKey },
24
+ ): HelperScriptEntry[] {
25
+ return [
26
+ {
27
+ name: 'update dependencies',
28
+ emoji: '♻️',
29
+ description: 'Update dependencies for the selection',
30
+ handler: async () => {
31
+ await updateDependencies(targets)
32
+ state.lastStep = 'update'
33
+ },
34
+ },
35
+ {
36
+ name: 'test',
37
+ emoji: '🧪',
38
+ description: 'Run tests',
39
+ handler: async () => {
40
+ if (targets.length === 1) await testSingle(targets[0])
41
+ else await testAll()
42
+ state.lastStep = 'test'
43
+ },
44
+ },
45
+ {
46
+ name: 'build',
47
+ emoji: '🏗️',
48
+ description: 'Build packages',
49
+ handler: async () => {
50
+ if (targets.length === 1) await buildSingle(targets[0])
51
+ else await buildAll()
52
+ state.lastStep = 'build'
53
+ },
54
+ },
55
+ {
56
+ name: 'publish',
57
+ emoji: '🚀',
58
+ description: 'Version, commit, and publish',
59
+ handler: async () => {
60
+ await ensureWorkingTreeCommitted()
61
+ if (targets.length > 1) await releaseMultiple(targets, packages)
62
+ else await releaseSingle(targets[0], packages)
63
+ state.lastStep = 'publish'
64
+ },
65
+ },
66
+ {
67
+ name: 'full',
68
+ emoji: '✅',
69
+ description: 'update → test → build → publish',
70
+ handler: async () => {
71
+ await updateDependencies(targets)
72
+ if (targets.length === 1) await testSingle(targets[0])
73
+ else await testAll()
74
+ if (targets.length === 1) await buildSingle(targets[0])
75
+ else await buildAll()
76
+ await ensureWorkingTreeCommitted()
77
+ if (targets.length > 1) await releaseMultiple(targets, packages)
78
+ else await releaseSingle(targets[0], packages)
79
+ state.lastStep = 'full'
80
+ },
81
+ },
82
+ {
83
+ name: 'back',
84
+ emoji: '↩️',
85
+ description: 'Pick packages again',
86
+ handler: async () => {
87
+ state.lastStep = 'back'
88
+ },
89
+ },
90
+ ]
91
+ }
92
+
93
+ export function buildPackageSelectionMenu(
94
+ packages: LoadedPackage[],
95
+ onStepComplete?: (lastStep?: StepKey) => void,
96
+ ): HelperScriptEntry[] {
97
+ const ordered = getOrderedPackages(packages)
98
+ const entries = ordered.map((pkg) => ({
99
+ name: pkg.substitute,
100
+ emoji: colors[pkg.color]('●'),
101
+ description: pkg.name ?? pkg.dirName,
102
+ handler: async () => {
103
+ const step = await runStepLoop([pkg], packages)
104
+ onStepComplete?.(step)
105
+ },
106
+ }))
107
+ entries.push({
108
+ name: 'All packages',
109
+ emoji: globalEmoji,
110
+ description: 'Select all packages',
111
+ handler: async () => {
112
+ const step = await runStepLoop(ordered, packages)
113
+ onStepComplete?.(step)
114
+ },
115
+ })
116
+ return entries
117
+ }
118
+
119
+ // IMPORTANT FIX: do not auto-pass argv derived from lastStep.
120
+ // This removes the infinite loop and returns to the step menu after each run.
121
+ export async function runStepLoop(
122
+ targets: LoadedPackage[],
123
+ packages: LoadedPackage[],
124
+ ): Promise<StepKey | undefined> {
125
+ const state: { lastStep?: StepKey } = {}
126
+ // Loop shows the step menu and executes a chosen action once.
127
+ // After the handler completes, show the menu again.
128
+ // Selecting "back" breaks and returns control to the package picker.
129
+ // No argv auto-execution here.
130
+ // eslint-disable-next-line no-constant-condition
131
+ while (true) {
132
+ state.lastStep = undefined
133
+ const entries = makeStepEntries(targets, packages, state)
134
+ await runHelperCli({
135
+ title: `Actions for ${targets.length === 1 ? targets[0].name : 'selected packages'}`,
136
+ scripts: entries,
137
+ argv: [], // <- key change
138
+ })
139
+ if (state.lastStep === 'back') return state.lastStep
140
+ // keep looping to show menu again
141
+ }
142
+ }