@emeryld/manager 0.2.0 → 0.2.2

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/src/git.ts DELETED
@@ -1,74 +0,0 @@
1
- // src/git.js
2
- import { spawn } from 'node:child_process'
3
- import { run } from './utils/run.js'
4
- import { logGlobal, colors } from './utils/log.js'
5
- import { rootDir } from './utils/run.js'
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
- }
package/src/helper-cli.ts DELETED
@@ -1,405 +0,0 @@
1
- import { spawn } from 'node:child_process'
2
- import { createRequire } from 'node:module'
3
- import readline from 'node:readline/promises'
4
- import { stdin as input, stdout as output } from 'node:process'
5
- import { fileURLToPath } from 'node:url'
6
- import path from 'node:path'
7
-
8
- const __filename = fileURLToPath(import.meta.url)
9
- const rootDir = path.resolve(path.dirname(__filename), '..')
10
- const managerRequire = createRequire(import.meta.url)
11
- let tsNodeLoaderPath: string | undefined
12
- function getTsNodeLoaderPath() {
13
- if (!tsNodeLoaderPath) {
14
- tsNodeLoaderPath = managerRequire.resolve('ts-node/esm.mjs')
15
- }
16
- return tsNodeLoaderPath
17
- }
18
-
19
- export function buildTsNodeRegisterImport(scriptPath: string) {
20
- const loader = getTsNodeLoaderPath()
21
- const code = [
22
- 'import { register } from "node:module";',
23
- 'import { pathToFileURL } from "node:url";',
24
- `register(${JSON.stringify(
25
- loader,
26
- )}, pathToFileURL(${JSON.stringify(scriptPath)}));`,
27
- ].join(' ')
28
- return `data:text/javascript,${encodeURIComponent(code)}`
29
- }
30
-
31
- const ansi = (code: number) => (text: string) => `\x1b[${code}m${text}\x1b[0m`
32
- const colors = {
33
- cyan: ansi(36),
34
- green: ansi(32),
35
- yellow: ansi(33),
36
- magenta: ansi(35),
37
- dim: ansi(2),
38
- }
39
-
40
- export interface HelperScriptContext {
41
- args: string[]
42
- entry: NormalizedScriptEntry
43
- rootDir: string
44
- }
45
-
46
- export type HelperScriptHandler = (
47
- context: HelperScriptContext,
48
- ) => Promise<void> | void
49
-
50
- export interface HelperScriptEntry {
51
- name: string
52
- emoji?: string
53
- script?: string
54
- description?: string
55
- handler?: HelperScriptHandler
56
- }
57
-
58
- interface NormalizedScriptEntry extends HelperScriptEntry {
59
- displayName: string
60
- emoji: string
61
- absoluteScript?: string
62
- metaLabel: string
63
- handler?: HelperScriptHandler
64
- }
65
-
66
- type ResolvedScriptEntry = NormalizedScriptEntry &
67
- Required<Pick<NormalizedScriptEntry, 'metaLabel'>>
68
-
69
- function normalizeScripts(entries: HelperScriptEntry[]): ResolvedScriptEntry[] {
70
- if (!Array.isArray(entries) || entries.length === 0) {
71
- throw new Error('runHelperCli requires at least one script definition.')
72
- }
73
-
74
- return entries.map((entry, index) => {
75
- if (!entry || typeof entry !== 'object') {
76
- throw new Error(`Script entry at index ${index} is not an object.`)
77
- }
78
- if (!entry.name || typeof entry.name !== 'string') {
79
- throw new Error(`Script entry at index ${index} is missing a "name".`)
80
- }
81
- const hasHandler = typeof entry.handler === 'function'
82
- const hasScript =
83
- typeof entry.script === 'string' && entry.script.length > 0
84
- if (!hasHandler && !hasScript) {
85
- throw new Error(
86
- `Script "${entry.name}" requires either a "script" path or a "handler" function.`,
87
- )
88
- }
89
- const absoluteScript =
90
- hasScript && path.isAbsolute(entry.script!)
91
- ? entry.script
92
- : hasScript
93
- ? path.join(rootDir, entry.script!)
94
- : undefined
95
- return {
96
- ...entry,
97
- emoji: entry.emoji ?? '🔧',
98
- displayName: entry.name.trim(),
99
- absoluteScript,
100
- script: hasScript ? entry.script : undefined,
101
- handler: hasHandler ? entry.handler : undefined,
102
- metaLabel:
103
- entry.description ?? (hasScript ? entry.script! : '[callback]'),
104
- }
105
- })
106
- }
107
-
108
- function findScriptEntry(
109
- entries: ResolvedScriptEntry[],
110
- key?: string,
111
- ): ResolvedScriptEntry | undefined {
112
- if (!key) return undefined
113
- const normalized = key.toLowerCase()
114
- return entries.find((entry) => {
115
- const nameMatch = entry.displayName.toLowerCase() === normalized
116
- const aliasMatch = entry.displayName.toLowerCase().includes(normalized)
117
- const scriptMatch = entry.script
118
- ? entry.script.toLowerCase().includes(normalized)
119
- : false
120
- return nameMatch || aliasMatch || scriptMatch
121
- })
122
- }
123
-
124
- function printScriptList(entries: ResolvedScriptEntry[], title?: string) {
125
- const heading = title
126
- ? colors.magenta(title)
127
- : colors.magenta('Available scripts')
128
- console.log(heading)
129
- entries.forEach((entry, index) => {
130
- console.log(
131
- ` ${colors.cyan(String(index + 1).padStart(2, ' '))} ${entry.emoji} ${entry.displayName} ${colors.dim(entry.metaLabel)}`,
132
- )
133
- })
134
- }
135
-
136
- async function promptWithReadline(
137
- entries: ResolvedScriptEntry[],
138
- title?: string,
139
- ) {
140
- printScriptList(entries, title)
141
- const rl = readline.createInterface({ input, output })
142
- try {
143
- while (true) {
144
- const answer = (
145
- await rl.question(colors.cyan('\nSelect a script by number or name: '))
146
- ).trim()
147
- if (!answer) continue
148
- const numeric = Number.parseInt(answer, 10)
149
- if (!Number.isNaN(numeric) && numeric >= 1 && numeric <= entries.length) {
150
- return entries[numeric - 1]
151
- }
152
- const byName = findScriptEntry(entries, answer)
153
- if (byName) return byName
154
- console.log(colors.yellow(`Could not find "${answer}". Try again.`))
155
- }
156
- } finally {
157
- rl.close()
158
- }
159
- }
160
-
161
- function formatInteractiveLines(
162
- entries: ResolvedScriptEntry[],
163
- selectedIndex: number,
164
- title?: string,
165
- ) {
166
- const heading = title
167
- ? colors.magenta(title)
168
- : colors.magenta('Available scripts')
169
- const lines = [heading]
170
- entries.forEach((entry, index) => {
171
- const isSelected = index === selectedIndex
172
- const pointer = isSelected ? `${colors.green('➤')} ` : ''
173
- const numberLabel = colors.cyan(`${index + 1}`.padStart(2, ' '))
174
- const label = isSelected
175
- ? colors.green(entry.displayName)
176
- : entry.displayName
177
- lines.push(
178
- `${pointer}${numberLabel}. ${entry.emoji} ${label} ${colors.dim(entry.metaLabel)}`,
179
- )
180
- })
181
- lines.push('')
182
- lines.push(
183
- colors.dim(
184
- 'Use ↑/↓ (or j/k) to move, digits (1-9,0 for 10) to run instantly, Enter to confirm, Esc/Ctrl+C to exit.',
185
- ),
186
- )
187
- return lines
188
- }
189
-
190
- function renderInteractiveList(lines: string[], previousLineCount: number) {
191
- if (previousLineCount > 0) {
192
- process.stdout.write(`\x1b[${previousLineCount}A`)
193
- process.stdout.write('\x1b[0J')
194
- }
195
- lines.forEach((line) => console.log(line))
196
- return lines.length
197
- }
198
-
199
- async function promptForScript(entries: ResolvedScriptEntry[], title?: string) {
200
- const supportsRawMode = typeof input.setRawMode === 'function' && input.isTTY
201
- if (!supportsRawMode) {
202
- return promptWithReadline(entries, title)
203
- }
204
- const wasRaw = input.isRaw
205
- if (!wasRaw) {
206
- input.setRawMode(true)
207
- input.resume()
208
- }
209
- process.stdout.write('\x1b[?25l')
210
-
211
- return new Promise<ResolvedScriptEntry>((resolve) => {
212
- let selectedIndex = 0
213
- let renderedLines = 0
214
-
215
- const cleanup = () => {
216
- if (renderedLines > 0) {
217
- process.stdout.write(`\x1b[${renderedLines}A`)
218
- process.stdout.write('\x1b[0J')
219
- renderedLines = 0
220
- }
221
- process.stdout.write('\x1b[?25h')
222
- if (!wasRaw) {
223
- input.setRawMode(false)
224
- input.pause()
225
- }
226
- input.removeListener('data', onData)
227
- }
228
-
229
- const commitSelection = (entry: ResolvedScriptEntry) => {
230
- cleanup()
231
- console.log()
232
- resolve(entry)
233
- }
234
-
235
- const render = () => {
236
- const lines = formatInteractiveLines(entries, selectedIndex, title)
237
- renderedLines = renderInteractiveList(lines, renderedLines)
238
- }
239
-
240
- const onData = (buffer: Buffer) => {
241
- const isArrowUp = buffer.equals(Buffer.from([0x1b, 0x5b, 0x41]))
242
- const isArrowDown = buffer.equals(Buffer.from([0x1b, 0x5b, 0x42]))
243
- const isCtrlC = buffer.length === 1 && buffer[0] === 0x03
244
- const isEnter =
245
- buffer.length === 1 && (buffer[0] === 0x0d || buffer[0] === 0x0a)
246
- const isEscape = buffer.length === 1 && buffer[0] === 0x1b
247
-
248
- if (isCtrlC || isEscape) {
249
- cleanup()
250
- process.exit(1)
251
- }
252
-
253
- if (
254
- isArrowUp ||
255
- (buffer.length === 1 && (buffer[0] === 0x6b || buffer[0] === 0x4b))
256
- ) {
257
- selectedIndex = (selectedIndex - 1 + entries.length) % entries.length
258
- render()
259
- return
260
- }
261
- if (
262
- isArrowDown ||
263
- (buffer.length === 1 && (buffer[0] === 0x6a || buffer[0] === 0x4a))
264
- ) {
265
- selectedIndex = (selectedIndex + 1) % entries.length
266
- render()
267
- return
268
- }
269
-
270
- if (isEnter) {
271
- commitSelection(entries[selectedIndex])
272
- return
273
- }
274
-
275
- if (buffer.length === 1 && buffer[0] >= 0x30 && buffer[0] <= 0x39) {
276
- const numericValue = buffer[0] === 0x30 ? 10 : buffer[0] - 0x30
277
- const selected = numericValue - 1
278
- if (selected >= 0 && selected < entries.length) {
279
- commitSelection(entries[selected])
280
- } else {
281
- process.stdout.write('\x07')
282
- }
283
- return
284
- }
285
-
286
- if (
287
- buffer.length === 1 &&
288
- ((buffer[0] >= 0x41 && buffer[0] <= 0x5a) ||
289
- (buffer[0] >= 0x61 && buffer[0] <= 0x7a))
290
- ) {
291
- const char = String.fromCharCode(buffer[0]).toLowerCase()
292
- const foundIndex = entries.findIndex((entry) =>
293
- entry.displayName.toLowerCase().startsWith(char),
294
- )
295
- if (foundIndex !== -1) {
296
- selectedIndex = foundIndex
297
- render()
298
- } else {
299
- process.stdout.write('\x07')
300
- }
301
- }
302
- }
303
-
304
- input.on('data', onData)
305
- render()
306
- })
307
- }
308
-
309
- function runEntry(entry: ResolvedScriptEntry, forwardedArgs: string[]) {
310
- const detail = entry.script
311
- ? path.relative(rootDir, entry.absoluteScript ?? entry.script)
312
- : (entry.metaLabel ?? '[callback]')
313
- console.log(
314
- `${entry.emoji} ${colors.green(`Running "${entry.displayName}"`)} ${colors.dim(detail)}`,
315
- )
316
- if (entry.handler) {
317
- return Promise.resolve(
318
- entry.handler({ args: forwardedArgs, entry, rootDir }),
319
- )
320
- }
321
- if (!entry.absoluteScript) {
322
- throw new Error(`Script "${entry.displayName}" is missing a resolved path.`)
323
- }
324
- const scriptPath = entry.absoluteScript
325
- const tsConfigPath = path.join(rootDir, 'tsconfig.base.json')
326
- const extension = path.extname(scriptPath).toLowerCase()
327
- const isTypeScript = extension === '.js' || extension === '.mts' || extension === '.cts'
328
- const command = process.execPath
329
- const execArgs = isTypeScript
330
- ? [
331
- '--import',
332
- buildTsNodeRegisterImport(scriptPath),
333
- scriptPath,
334
- ...forwardedArgs,
335
- ]
336
- : [scriptPath, ...forwardedArgs]
337
- return new Promise<void>((resolve, reject) => {
338
- const child = spawn(command, execArgs, {
339
- cwd: rootDir,
340
- stdio: 'inherit',
341
- env: isTypeScript
342
- ? { ...process.env, TS_NODE_PROJECT: tsConfigPath }
343
- : process.env,
344
- shell: process.platform === 'win32' && isTypeScript,
345
- })
346
- child.on('close', (code) => {
347
- if (code === 0) resolve()
348
- else
349
- reject(
350
- new Error(`Script "${entry.displayName}" exited with code ${code}`),
351
- )
352
- })
353
- })
354
- }
355
-
356
- export interface HelperCliOptions {
357
- scripts: HelperScriptEntry[]
358
- title?: string
359
- argv?: string[]
360
- }
361
-
362
- export async function runHelperCli({
363
- scripts,
364
- title = 'Helper CLI',
365
- argv = process.argv.slice(2),
366
- }: HelperCliOptions) {
367
- const normalized = normalizeScripts(scripts)
368
- let args = Array.isArray(argv) ? [...argv] : []
369
- const separatorIndex = args.indexOf('--')
370
- if (separatorIndex !== -1) {
371
- args = [...args.slice(0, separatorIndex), ...args.slice(separatorIndex + 1)]
372
- }
373
- const [firstArg, ...restArgs] = args
374
-
375
- if (firstArg === '--list' || firstArg === '-l') {
376
- printScriptList(normalized, title)
377
- return
378
- }
379
-
380
- if (firstArg === '--help' || firstArg === '-h') {
381
- console.log(colors.magenta('Usage:'))
382
- console.log(' pnpm run <cli> [script] [...args]')
383
- console.log('\nFlags:')
384
- console.log(' --list Show available scripts')
385
- console.log(' --help Show this information')
386
- return
387
- }
388
-
389
- const argLooksLikeScript = firstArg && !firstArg.startsWith('-')
390
- if (argLooksLikeScript) {
391
- const requested = findScriptEntry(normalized, firstArg)
392
- if (requested) {
393
- await runEntry(requested, restArgs)
394
- return
395
- }
396
- console.log(
397
- colors.yellow(
398
- `Unknown script "${firstArg}". Falling back to interactive selection…`,
399
- ),
400
- )
401
- }
402
-
403
- const selection = await promptForScript(normalized, title)
404
- await runEntry(selection, [])
405
- }
package/src/menu.ts DELETED
@@ -1,142 +0,0 @@
1
- // src/menu.js
2
- import type { HelperScriptEntry } from './helper-cli.js'
3
- import type { LoadedPackage } from './utils/log.js'
4
- import { colors, globalEmoji } from './utils/log.js'
5
- import {
6
- updateDependencies,
7
- testAll,
8
- testSingle,
9
- buildAll,
10
- buildSingle,
11
- } from './workspace.js'
12
-
13
- import { releaseMultiple, releaseSingle } from './release.js'
14
- import { getOrderedPackages } from './packages.js'
15
- import { runHelperCli } from './helper-cli.js'
16
- import { ensureWorkingTreeCommitted } from './preflight.js'
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
- }