@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/packages.ts DELETED
@@ -1,305 +0,0 @@
1
- // src/packages.js
2
- import path from 'node:path'
3
- import { pathToFileURL } from 'node:url'
4
- import { readdir, readFile } from 'node:fs/promises'
5
- import type { PackageColor, LoadedPackage } from './utils/log.js'
6
-
7
- const rootDir = process.cwd()
8
- export const packagesDir = path.join(rootDir, 'packages')
9
-
10
- type ManifestEntry = {
11
- name: string
12
- path: string
13
- color?: PackageColor
14
- substitute?: string
15
- }
16
-
17
- type ManifestState = {
18
- entries: ManifestEntry[]
19
- byName: Map<string, ManifestEntry>
20
- byPath: Map<string, ManifestEntry>
21
- orderedPackagePaths: string[]
22
- }
23
-
24
- const colorPalette: PackageColor[] = ['cyan', 'green', 'yellow', 'magenta', 'red']
25
- let manifestState: ManifestState | undefined
26
-
27
- function manifestFilePath() {
28
- return path.join(rootDir, 'scripts', 'packages.mjs')
29
- }
30
-
31
- function isManifestMissing(error: unknown) {
32
- if (typeof error !== 'object' || error === null) return false
33
- const code = (error as { code?: string }).code
34
- return code === 'ERR_MODULE_NOT_FOUND' || code === 'ENOENT'
35
- }
36
-
37
- function normalizeManifestPath(value: string) {
38
- const absolute = path.resolve(rootDir, value || '')
39
- let relative = path.relative(rootDir, absolute)
40
- if (!relative) return ''
41
- relative = relative.replace(/\\/g, '/')
42
- return relative.replace(/^(?:\.\/)+/, '')
43
- }
44
-
45
- function colorFromSeed(seed: string) {
46
- const normalized = `${seed}`.trim() || 'package'
47
- let hash = 0
48
- for (let i = 0; i < normalized.length; i++) {
49
- hash = (hash * 31 + normalized.charCodeAt(i)) >>> 0
50
- }
51
- return colorPalette[hash % colorPalette.length]
52
- }
53
-
54
- function deriveSubstitute(name: string) {
55
- const trimmed = (name || '').trim()
56
- if (!trimmed) return ''
57
- const segments = trimmed.split(/[@\/\-]/).filter(Boolean)
58
- const transformed = segments
59
- .map((segment) => segment.slice(0, 2))
60
- .filter(Boolean)
61
- .join(' ')
62
- return transformed || trimmed
63
- }
64
-
65
- async function loadWorkspaceManifest(): Promise<ManifestEntry[] | undefined> {
66
- const manifestPath = manifestFilePath()
67
- try {
68
- const manifestModule = await import(pathToFileURL(manifestPath).href)
69
- if (Array.isArray(manifestModule?.PACKAGE_MANIFEST)) {
70
- return manifestModule.PACKAGE_MANIFEST
71
- }
72
- } catch (error) {
73
- if (isManifestMissing(error)) return undefined
74
- throw error
75
- }
76
- return undefined
77
- }
78
-
79
- async function inferManifestFromWorkspace(): Promise<ManifestEntry[]> {
80
- try {
81
- const entries = await readdir(packagesDir, { withFileTypes: true })
82
- const manifest: ManifestEntry[] = []
83
- for (const entry of entries) {
84
- if (!entry.isDirectory()) continue
85
- const pkgJsonPath = path.join(packagesDir, entry.name, 'package.json')
86
- try {
87
- const raw = await readFile(pkgJsonPath, 'utf8')
88
- const json = JSON.parse(raw) as { name?: string }
89
- const pkgName = json.name?.trim() || entry.name
90
- manifest.push({
91
- name: pkgName,
92
- path: normalizeManifestPath(path.relative(rootDir, path.join(packagesDir, entry.name))),
93
- color: colorFromSeed(pkgName),
94
- substitute: deriveSubstitute(pkgName),
95
- })
96
- } catch {
97
- continue
98
- }
99
- }
100
- return manifest
101
- } catch {
102
- return []
103
- }
104
- }
105
-
106
- function mergeManifestEntries(
107
- inferred: ManifestEntry[],
108
- overrides?: ManifestEntry[],
109
- ) {
110
- const normalizedOverrides = new Map<string, ManifestEntry>()
111
- overrides?.forEach((entry) => {
112
- const normalized = normalizeManifestPath(entry.path)
113
- if (!normalized) return
114
- normalizedOverrides.set(normalized, { ...entry, path: normalized })
115
- })
116
-
117
- const merged: ManifestEntry[] = []
118
- for (const baseEntry of inferred) {
119
- const normalized = normalizeManifestPath(baseEntry.path)
120
- const override = normalizedOverrides.get(normalized)
121
- if (override) {
122
- normalizedOverrides.delete(normalized)
123
- const name = override.name || baseEntry.name
124
- const color = override.color ?? baseEntry.color ?? colorFromSeed(name)
125
- const substitute =
126
- override.substitute ?? baseEntry.substitute ?? deriveSubstitute(name) ?? name
127
- merged.push({ name, path: normalized, color, substitute })
128
- } else {
129
- merged.push({ ...baseEntry, path: normalized })
130
- }
131
- }
132
-
133
- normalizedOverrides.forEach((entry) => {
134
- const name = entry.name || path.basename(entry.path) || 'package'
135
- const color = entry.color ?? colorFromSeed(name)
136
- const substitute = entry.substitute ?? deriveSubstitute(name) ?? name
137
- merged.push({ name, path: entry.path, color, substitute })
138
- })
139
-
140
- return merged
141
- }
142
-
143
- async function ensureManifestState() {
144
- if (manifestState) return manifestState
145
- const [workspaceManifest, inferred] = await Promise.all([
146
- loadWorkspaceManifest(),
147
- inferManifestFromWorkspace(),
148
- ])
149
- const entries = mergeManifestEntries(inferred, workspaceManifest)
150
- const byName = new Map(entries.map((pkg) => [pkg.name.toLowerCase(), pkg]))
151
- const byPath = new Map(entries.map((pkg) => [pkg.path.toLowerCase(), pkg]))
152
- manifestState = {
153
- entries,
154
- byName,
155
- byPath,
156
- orderedPackagePaths: entries.map((pkg) => pkg.path.toLowerCase()),
157
- }
158
- return manifestState
159
- }
160
-
161
- export async function loadPackages(): Promise<LoadedPackage[]> {
162
- const entries = await readdir(packagesDir, { withFileTypes: true })
163
- const packages: LoadedPackage[] = []
164
- const { byName, byPath } = await ensureManifestState()
165
- for (const entry of entries) {
166
- if (!entry.isDirectory()) continue
167
- const pkgJsonPath = path.join(packagesDir, entry.name, 'package.json')
168
- try {
169
- const raw = await readFile(pkgJsonPath, 'utf8')
170
- const json = JSON.parse(raw)
171
- const pkgName = (json.name as string | undefined) ?? entry.name
172
- const relativePath = normalizeManifestPath(
173
- path.relative(rootDir, path.join(packagesDir, entry.name)),
174
- )
175
- const meta =
176
- byPath.get(relativePath.toLowerCase()) ??
177
- byName.get(pkgName.toLowerCase())
178
- const substitute =
179
- meta?.substitute ?? deriveSubstitute(pkgName) ?? entry.name
180
- const color = meta?.color ?? colorFromSeed(pkgName)
181
- packages.push({
182
- dirName: entry.name,
183
- path: path.join(packagesDir, entry.name),
184
- packageJsonPath: pkgJsonPath,
185
- json,
186
- version: json.version,
187
- name: pkgName,
188
- substitute,
189
- color,
190
- })
191
- } catch (error) {
192
- console.warn(`Skipping ${entry.name}: ${error}`)
193
- }
194
- }
195
- return packages
196
- }
197
-
198
- export function resolvePackage(packages: LoadedPackage[], key?: string) {
199
- if (!key) return undefined
200
- const normalized = key.toLowerCase()
201
- return packages.find((pkg) => {
202
- const dirMatch = pkg.dirName.toLowerCase() === normalized
203
- const nameMatch = (pkg.name ?? '').toLowerCase() === normalized
204
- const aliasMatch = (pkg.substitute ?? '').toLowerCase() === normalized
205
- const fuzzyMatch = (pkg.name ?? '').toLowerCase().includes(normalized)
206
- return dirMatch || nameMatch || aliasMatch || fuzzyMatch
207
- })
208
- }
209
-
210
- /**
211
- * Topologically sort by internal dependencies.
212
- * Falls back to manifest order for ties to preserve stability.
213
- */
214
- export function getOrderedPackages(packages: LoadedPackage[]) {
215
- if (packages.length <= 1) return [...packages]
216
-
217
- // Build lookup by package name
218
- const byName = new Map<string, LoadedPackage>()
219
- const byDir = new Map<string, LoadedPackage>()
220
- for (const p of packages) {
221
- if (p.name) byName.set(p.name, p)
222
- byDir.set(p.dirName, p)
223
- }
224
-
225
- // Build adjacency list: edge dep -> pkg (dep must publish first)
226
- const depsOf = (p: LoadedPackage) => {
227
- const j = p.json ?? {}
228
- const fields = [
229
- 'dependencies',
230
- 'devDependencies',
231
- 'peerDependencies',
232
- ] as const
233
- const names = new Set<string>()
234
- for (const f of fields) {
235
- const obj = j[f] as Record<string, string> | undefined
236
- if (!obj) continue
237
- for (const k of Object.keys(obj)) names.add(k)
238
- }
239
- return [...names]
240
- .map((n) => byName.get(n))
241
- .filter((x): x is LoadedPackage => Boolean(x))
242
- }
243
-
244
- const nodes = new Set(packages.map((p) => p.dirName))
245
- const inDegree = new Map<string, number>()
246
- const adj = new Map<string, Set<string>>()
247
- for (const p of packages) {
248
- inDegree.set(p.dirName, 0)
249
- adj.set(p.dirName, new Set())
250
- }
251
- for (const p of packages) {
252
- for (const dep of depsOf(p)) {
253
- // dep -> p
254
- if (!nodes.has(dep.dirName)) continue
255
- if (dep.dirName === p.dirName) continue
256
- const set = adj.get(dep.dirName)!
257
- if (!set.has(p.dirName)) {
258
- set.add(p.dirName)
259
- inDegree.set(p.dirName, (inDegree.get(p.dirName) ?? 0) + 1)
260
- }
261
- }
262
- }
263
-
264
- // Kahn's algorithm with stable tie-break using manifest order then alpha
265
- const orderedFiles = manifestState?.orderedPackagePaths ?? []
266
- const priorityIndex = new Map<string, number>()
267
- orderedFiles.forEach((file, i) => priorityIndex.set(file, i))
268
- const pickOrder = (a: string, b: string) => {
269
- const pa = priorityIndex.get(a) ?? Number.MAX_SAFE_INTEGER
270
- const pb = priorityIndex.get(b) ?? Number.MAX_SAFE_INTEGER
271
- if (pa !== pb) return pa - pb
272
- return a.localeCompare(b)
273
- }
274
-
275
- const queue = [
276
- ...[...nodes].filter((n) => (inDegree.get(n) ?? 0) === 0),
277
- ].sort(pickOrder)
278
- const result: string[] = []
279
- while (queue.length) {
280
- const n = queue.shift()!
281
- result.push(n)
282
- for (const m of adj.get(n)!) {
283
- inDegree.set(m, (inDegree.get(m) ?? 0) - 1)
284
- if ((inDegree.get(m) ?? 0) === 0) {
285
- // insert keeping order
286
- const idx = queue.findIndex((x) => pickOrder(m, x) < 0)
287
- if (idx === -1) queue.push(m)
288
- else queue.splice(idx, 0, m)
289
- }
290
- }
291
- }
292
-
293
- // If cycle, append remaining in original manifest order
294
- const remaining = [...nodes]
295
- .filter((n) => !result.includes(n))
296
- .sort(pickOrder)
297
- const orderedDirNames = [...result, ...remaining]
298
- const ordered = orderedDirNames
299
- .map((d) => byDir.get(d))
300
- .filter((p): p is LoadedPackage => Boolean(p))
301
-
302
- // Keep any not in the set at the end (shouldn't happen)
303
- const tail = packages.filter((p) => !ordered.includes(p))
304
- return [...ordered, ...tail]
305
- }
package/src/preflight.ts DELETED
@@ -1,26 +0,0 @@
1
- // src/preflight.js
2
- import { collectGitStatus } from './git.js'
3
-
4
- import { colors, logGlobal } from './utils/log.js'
5
- import { run } from './utils/run.js'
6
- import { gitAdd, gitCommit } from './git.js'
7
- import { askLine } from './prompts.js'
8
-
9
- export async function ensureWorkingTreeCommitted() {
10
- const changes = await collectGitStatus()
11
- if (changes.length === 0) return
12
- logGlobal('Detected pending git changes:', colors.yellow)
13
- changes.forEach((line) => console.log(colors.dim(` • ${line}`)))
14
- let message = ''
15
- while (!message) {
16
- // eslint-disable-next-line no-await-in-loop
17
- message = await askLine('Enter commit message to capture current changes: ')
18
- if (!message) console.log(colors.red('Commit message cannot be empty.'))
19
- }
20
- logGlobal('Staging existing changes…', colors.cyan)
21
- await run('git', ['add', '--all'])
22
- logGlobal('Creating commit…', colors.cyan)
23
- await gitCommit(message)
24
- logGlobal('Pushing commit…', colors.cyan)
25
- await run('git', ['push'])
26
- }
package/src/prompts.ts DELETED
@@ -1,93 +0,0 @@
1
- // src/prompts.js
2
- import readline from 'node:readline/promises'
3
- import { stdin as input, stdout as output } from 'node:process'
4
- import { colors } from './utils/log.js'
5
-
6
- export type YesNoAll = 'yes' | 'no' | 'all'
7
-
8
- export const publishCliState = { autoConfirmAll: false }
9
-
10
- export function promptSingleKey<T>(
11
- message: string,
12
- resolver: (key: string, raw: string) => T | undefined,
13
- ) {
14
- const supportsRawMode = typeof input.setRawMode === 'function' && input.isTTY
15
- if (!supportsRawMode) {
16
- return (async () => {
17
- const rl = readline.createInterface({ input, output })
18
- try {
19
- // eslint-disable-next-line no-constant-condition
20
- while (true) {
21
- const answer = (await rl.question(message)).trim()
22
- const key = answer.toLowerCase()
23
- const result = resolver(key, answer)
24
- if (result !== undefined) return result
25
- }
26
- } finally {
27
- rl.close()
28
- }
29
- })()
30
- }
31
-
32
- return new Promise<T>((resolve) => {
33
- const wasRaw = input.isRaw
34
- if (!wasRaw) {
35
- input.setRawMode(true)
36
- input.resume()
37
- }
38
- process.stdout.write(message)
39
- const onData = (buffer: Buffer) => {
40
- const str = buffer.toString()
41
- if (str === '\u0003') {
42
- process.stdout.write('\n')
43
- process.exit(1)
44
- }
45
- const key = str.toLowerCase()
46
- const result = resolver(key, str)
47
- if (result !== undefined) {
48
- process.stdout.write('\n')
49
- cleanup()
50
- resolve(result)
51
- }
52
- }
53
- const cleanup = () => {
54
- input.off('data', onData)
55
- if (!wasRaw) {
56
- input.setRawMode(false)
57
- input.pause()
58
- }
59
- }
60
- input.on('data', onData)
61
- })
62
- }
63
-
64
- export async function askLine(question: string) {
65
- const rl = readline.createInterface({ input, output })
66
- try {
67
- const answer = await rl.question(question)
68
- return answer.trim()
69
- } finally {
70
- rl.close()
71
- }
72
- }
73
-
74
- export async function promptYesNoAll(question: string): Promise<YesNoAll> {
75
- if (publishCliState.autoConfirmAll) {
76
- console.log(`${question} (auto-confirmed via "all")`)
77
- return 'yes'
78
- }
79
- const result = await promptSingleKey<YesNoAll>(
80
- `${question} (y/n/a): `,
81
- (key) => {
82
- if (key === 'y') return 'yes'
83
- if (key === 'n') return 'no'
84
- if (key === 'a') return 'all'
85
- return undefined
86
- },
87
- )
88
- if (result === 'all') {
89
- publishCliState.autoConfirmAll = true
90
- return 'yes'
91
- }
92
- return result
93
- }
package/src/publish.ts DELETED
@@ -1,183 +0,0 @@
1
- // src/publish.js
2
- import { runHelperCli } from './helper-cli.js'
3
- import { buildPackageSelectionMenu, runStepLoop, type StepKey } from './menu.js'
4
- import { getOrderedPackages, loadPackages, resolvePackage } from './packages.js'
5
- import {
6
- releaseMultiple,
7
- releaseSingle,
8
- type PublishOptions,
9
- } from './release.js'
10
- import { ensureWorkingTreeCommitted } from './preflight.js'
11
- import { publishCliState } from './prompts.js'
12
-
13
- type Parsed = {
14
- selectionArg?: string
15
- helperArgs: string[]
16
- // non-interactive publish options
17
- nonInteractive: boolean
18
- bumpType?: 'patch' | 'minor' | 'major'
19
- syncVersion?: string
20
- tag?: string
21
- dryRun?: boolean
22
- provenance?: boolean
23
- noop?: boolean
24
- }
25
-
26
- function resolveTargetsFromArg(
27
- packages: Awaited<ReturnType<typeof loadPackages>>,
28
- arg: string,
29
- ) {
30
- if (arg.toLowerCase() === 'all') return getOrderedPackages(packages)
31
- const pkg = resolvePackage(packages, arg)
32
- if (!pkg) throw new Error(`Package "${arg}" not found.`)
33
- return [pkg]
34
- }
35
-
36
- function parseCliArgs(args: string[]): Parsed {
37
- let selectionArg: string | undefined
38
- const helperArgs: string[] = []
39
-
40
- let nonInteractive = false
41
- let bumpType: Parsed['bumpType']
42
- let syncVersion: string | undefined
43
- let tag: string | undefined
44
- let dryRun = false
45
- let provenance = false
46
- let noop = false
47
-
48
- for (let i = 0; i < args.length; i++) {
49
- const arg = args[i]
50
-
51
- // selectionArg is the first non-flag token
52
- if (!selectionArg && !arg.startsWith('-')) {
53
- selectionArg = arg
54
- helperArgs.push(arg)
55
- continue
56
- }
57
-
58
- if (
59
- arg === '--yes' ||
60
- arg === '-y' ||
61
- arg === '--non-interactive' ||
62
- arg === '--ci'
63
- ) {
64
- nonInteractive = true
65
- continue
66
- }
67
- if (arg === '--bump') {
68
- bumpType = args[++i] as Parsed['bumpType']
69
- continue
70
- }
71
- if (arg === '--sync') {
72
- syncVersion = args[++i]
73
- continue
74
- }
75
- if (arg === '--tag') {
76
- tag = args[++i]
77
- continue
78
- }
79
- if (arg === '--dry-run') {
80
- dryRun = true
81
- continue
82
- }
83
- if (arg === '--provenance') {
84
- provenance = true
85
- continue
86
- }
87
- if (arg === '--noop') {
88
- noop = true
89
- continue
90
- }
91
-
92
- // pass-through for helper CLI
93
- helperArgs.push(arg)
94
- }
95
-
96
- return {
97
- selectionArg,
98
- helperArgs,
99
- nonInteractive,
100
- bumpType,
101
- syncVersion,
102
- tag,
103
- dryRun,
104
- provenance,
105
- noop,
106
- }
107
- }
108
-
109
- function optsFromParsed(p: Parsed): PublishOptions {
110
- return {
111
- nonInteractive: p.nonInteractive,
112
- bumpType: p.bumpType,
113
- syncVersion: p.syncVersion,
114
- tag: p.tag,
115
- dryRun: p.dryRun,
116
- provenance: p.provenance,
117
- }
118
- }
119
-
120
- async function runPackageSelectionLoop(
121
- packages: Awaited<ReturnType<typeof loadPackages>>,
122
- helperArgs: string[],
123
- ) {
124
- let argv = [...helperArgs]
125
- // eslint-disable-next-line no-constant-condition
126
- while (true) {
127
- let lastStep: StepKey | undefined
128
- await runHelperCli({
129
- title: 'Pick one of the packages or all',
130
- scripts: buildPackageSelectionMenu(packages, (step) => {
131
- lastStep = step
132
- }),
133
- argv, // pass through CLI args only once; subsequent loops rely on selection
134
- })
135
- argv = []
136
- if (lastStep !== 'back') return
137
- }
138
- }
139
-
140
- async function main(): Promise<void> {
141
- const cliArgs = process.argv.slice(2)
142
- const parsed = parseCliArgs(cliArgs)
143
- const packages = await loadPackages()
144
- if (packages.length === 0) throw new Error('No packages found in ./packages')
145
-
146
- // If user provided non-interactive flags, run headless path
147
- if (parsed.nonInteractive) {
148
- publishCliState.autoConfirmAll = true
149
-
150
- if (!parsed.selectionArg) {
151
- throw new Error(
152
- 'Non-interactive mode requires a package selection: <pkg> or "all".',
153
- )
154
- }
155
- if (!parsed.bumpType && !parsed.syncVersion && !parsed.noop) {
156
- throw new Error(
157
- 'Non-interactive mode requires one of: --bump <type> | --sync <version> | --noop',
158
- )
159
- }
160
-
161
- const targets = resolveTargetsFromArg(packages, parsed.selectionArg)
162
- const opts = optsFromParsed(parsed)
163
-
164
- await ensureWorkingTreeCommitted()
165
- if (targets.length > 1) await releaseMultiple(targets, packages, opts)
166
- else await releaseSingle(targets[0], packages, opts)
167
- return
168
- }
169
-
170
- // Interactive flow (unchanged): selection menu then step menu
171
- if (parsed.selectionArg) {
172
- const targets = resolveTargetsFromArg(packages, parsed.selectionArg)
173
- await runStepLoop(targets, packages)
174
- return
175
- }
176
-
177
- await runPackageSelectionLoop(packages, parsed.helperArgs)
178
- }
179
-
180
- main().catch((error) => {
181
- console.error(error)
182
- process.exit(1)
183
- })