@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,30 @@
1
+ // src/utils/run.ts
2
+ import { spawn, type SpawnOptions } 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
+ export const rootDir = path.resolve(path.dirname(__filename), '..', '..')
8
+
9
+ export function run(
10
+ command: string,
11
+ args: string[],
12
+ options: SpawnOptions = {},
13
+ ) {
14
+ return new Promise<void>((resolve, reject) => {
15
+ const child = spawn(command, args, {
16
+ cwd: rootDir,
17
+ stdio: 'inherit',
18
+ ...options,
19
+ })
20
+ child.on('close', (code) => {
21
+ if (code === 0) resolve()
22
+ else
23
+ reject(
24
+ new Error(
25
+ `Command "${command} ${args.join(' ')}" exited with ${code}`,
26
+ ),
27
+ )
28
+ })
29
+ })
30
+ }
@@ -0,0 +1,289 @@
1
+ // src/workspace.ts
2
+ import { spawnSync } from 'node:child_process'
3
+ import { run, rootDir } from './utils/run.ts'
4
+ import type { LoadedPackage } from './utils/log.ts'
5
+ import { logGlobal, logPkg, colors } from './utils/log.ts'
6
+ import { collectGitStatus, gitAdd, gitCommit } from './git.ts'
7
+ import { askLine, promptSingleKey } from './prompts.ts'
8
+
9
+ const dependencyFiles = new Set([
10
+ 'package.json',
11
+ 'pnpm-lock.yaml',
12
+ 'package-lock.json',
13
+ 'npm-shrinkwrap.json',
14
+ 'pnpm-workspace.yaml',
15
+ ])
16
+
17
+ function extractPathFromStatus(line: string) {
18
+ const match = line.match(/^[AMDR\? ][\S\?]\s+(.*)$/)
19
+ if (!match) return undefined
20
+ const rawPath = match[1].trim().replace(/"/g, '')
21
+ const normalized = rawPath.includes(' -> ')
22
+ ? rawPath.split(' -> ').pop()
23
+ : rawPath
24
+ return normalized
25
+ }
26
+
27
+ function dependencyPathsFromStatus(status: string[]) {
28
+ return status
29
+ .map(extractPathFromStatus)
30
+ .filter((p): p is string =>
31
+ Boolean(p && dependencyFiles.has(p.split('/').pop() ?? '')),
32
+ )
33
+ }
34
+
35
+ function formatPkgLabel(pkg: LoadedPackage) {
36
+ return pkg.substitute ?? pkg.name ?? pkg.dirName
37
+ }
38
+
39
+ function logDependencyChanges(paths: string[], targets: LoadedPackage[]) {
40
+ if (paths.length === 0) return
41
+ const byDir = new Map(targets.map((t) => [t.dirName, formatPkgLabel(t)]))
42
+
43
+ logGlobal('Dependency file changes detected:', colors.cyan)
44
+ paths.forEach((p) => {
45
+ const parts = p.split('/')
46
+ const file = parts[parts.length - 1]
47
+ const pkgIndex = parts.indexOf('packages')
48
+ if (pkgIndex !== -1 && parts[pkgIndex + 1]) {
49
+ const dir = parts[pkgIndex + 1]
50
+ const fileLabel = parts.slice(pkgIndex + 2).join('/') || 'package.json'
51
+ console.log(
52
+ ` • ${colors.dim(`${byDir.get(dir) ?? dir} (${fileLabel})`)}`,
53
+ )
54
+ return
55
+ }
56
+ if (
57
+ file === 'pnpm-lock.yaml' ||
58
+ file === 'package-lock.json' ||
59
+ file === 'npm-shrinkwrap.json'
60
+ ) {
61
+ console.log(` • ${colors.dim(`workspace lockfile (${file})`)}`)
62
+ return
63
+ }
64
+ if (file === 'pnpm-workspace.yaml') {
65
+ console.log(` • ${colors.dim('workspace pnpm-workspace.yaml')}`)
66
+ return
67
+ }
68
+ if (file === 'package.json') {
69
+ console.log(` • ${colors.dim('workspace package.json')}`)
70
+ return
71
+ }
72
+ console.log(` • ${colors.dim(p)}`)
73
+ })
74
+ }
75
+
76
+ type VersionChange = { dep: string; from: string; to: string; label: string }
77
+
78
+ function readDiff(path: string) {
79
+ const res = spawnSync('git', ['diff', '--unified=0', '--', path], {
80
+ cwd: rootDir,
81
+ encoding: 'utf8',
82
+ })
83
+ if (typeof res.stdout === 'string') return res.stdout
84
+ return ''
85
+ }
86
+
87
+ function parseVersionChanges(path: string, label: string): VersionChange[] {
88
+ const diff = readDiff(path)
89
+ if (!diff) return []
90
+
91
+ const changes = new Map<string, { from?: string; to?: string }>()
92
+ const lineRe = /^[\-\+]\s+"([^"]+)":\s*"([^"]+)"/
93
+
94
+ diff.split('\n').forEach((line) => {
95
+ if (line.startsWith('+++') || line.startsWith('---')) return
96
+ const match = line.match(lineRe)
97
+ if (!match) return
98
+ const [, dep, version] = match
99
+ const entry = changes.get(dep) ?? {}
100
+ if (line.startsWith('-')) entry.from = version
101
+ if (line.startsWith('+')) entry.to = version
102
+ changes.set(dep, entry)
103
+ })
104
+
105
+ return [...changes.entries()]
106
+ .filter(([, v]) => v.from && v.to && v.from !== v.to)
107
+ .map(([dep, v]) => ({
108
+ dep,
109
+ from: v.from as string,
110
+ to: v.to as string,
111
+ label,
112
+ }))
113
+ }
114
+
115
+ function summarizeVersionChanges(paths: string[], targets: LoadedPackage[]) {
116
+ const byDir = new Map(targets.map((t) => [t.dirName, formatPkgLabel(t)]))
117
+ const changes: VersionChange[] = []
118
+
119
+ paths
120
+ .filter((p) => p.endsWith('package.json'))
121
+ .forEach((p) => {
122
+ const parts = p.split('/')
123
+ const pkgIndex = parts.indexOf('packages')
124
+ const label =
125
+ pkgIndex !== -1 && parts[pkgIndex + 1]
126
+ ? (byDir.get(parts[pkgIndex + 1]) ?? parts[pkgIndex + 1])
127
+ : 'workspace'
128
+ changes.push(...parseVersionChanges(p, label))
129
+ })
130
+
131
+ if (changes.length === 0) return ''
132
+
133
+ const grouped = new Map<string, VersionChange[]>()
134
+ changes.forEach((c) => {
135
+ const list = grouped.get(c.label) ?? []
136
+ list.push(c)
137
+ grouped.set(c.label, list)
138
+ })
139
+
140
+ const summaries: string[] = []
141
+ grouped.forEach((list, label) => {
142
+ const slice = list.slice(0, 3).map((c) => `${c.dep} ${c.from}→${c.to}`)
143
+ const extra =
144
+ list.length > slice.length ? ` (+${list.length - slice.length} more)` : ''
145
+ summaries.push(`${label}: ${slice.join(', ')}${extra}`)
146
+ })
147
+
148
+ return summaries.join('; ')
149
+ }
150
+
151
+ function buildUpdateCommitMessage(paths: string[], targets: LoadedPackage[]) {
152
+ const changeSummary = summarizeVersionChanges(paths, targets)
153
+ if (changeSummary) return `chore(deps): ${changeSummary}`
154
+
155
+ const labels = new Set<string>()
156
+ const byDir = new Map(targets.map((t) => [t.dirName, formatPkgLabel(t)]))
157
+ paths.forEach((p) => {
158
+ const parts = p.split('/')
159
+ const pkgIndex = parts.indexOf('packages')
160
+ if (pkgIndex !== -1 && parts[pkgIndex + 1]) {
161
+ const dir = parts[pkgIndex + 1]
162
+ labels.add(byDir.get(dir) ?? dir)
163
+ return
164
+ }
165
+ if (parts[parts.length - 1] === 'package.json') {
166
+ labels.add('workspace deps')
167
+ }
168
+ })
169
+
170
+ if (labels.size === 0 && targets.length === 1) {
171
+ labels.add(formatPkgLabel(targets[0]))
172
+ }
173
+
174
+ const labelList = [...labels]
175
+ if (labelList.length === 0) return 'chore(deps): update dependencies'
176
+ if (labelList.length === 1) return `chore(deps): update ${labelList[0]}`
177
+ return `chore(deps): update ${labelList.join(', ')}`
178
+ }
179
+
180
+ async function promptCommitMessage(proposed: string) {
181
+ const confirm = await promptSingleKey<'yes' | 'no'>(
182
+ `Use commit message "${proposed}"? (y/n): `,
183
+ (key) => {
184
+ if (key === 'y') return 'yes'
185
+ if (key === 'n') return 'no'
186
+ return undefined
187
+ },
188
+ )
189
+ if (confirm === 'yes') return proposed
190
+
191
+ let custom = ''
192
+ while (!custom.trim()) {
193
+ // eslint-disable-next-line no-await-in-loop
194
+ custom = await askLine('Enter commit message: ')
195
+ if (!custom.trim())
196
+ console.log(colors.red('Commit message cannot be empty.'))
197
+ }
198
+ return custom.trim()
199
+ }
200
+
201
+ export async function runCleanInstall() {
202
+ logGlobal('Cleaning workspace…', colors.cyan)
203
+ await run('pnpm', ['run', 'clean'])
204
+ logGlobal('Reinstalling dependencies…', colors.cyan)
205
+ await run('pnpm', ['install'])
206
+ }
207
+
208
+ export async function updateDependencies(targets: LoadedPackage[]) {
209
+ const preStatus = await collectGitStatus()
210
+
211
+ if (targets.length === 1) {
212
+ const filterArg = targets[0].name ?? `./packages/${targets[0].dirName}`
213
+ logPkg(targets[0], `Updating dependencies…`)
214
+ await run('pnpm', ['-r', '--filter', filterArg, 'update'])
215
+ } else {
216
+ logGlobal('Updating dependencies across the workspace…', colors.cyan)
217
+ await run('pnpm', ['-r', 'update'])
218
+ }
219
+
220
+ const postStatus = await collectGitStatus()
221
+ const depPaths = dependencyPathsFromStatus(postStatus)
222
+ const uniqueDepPaths = [...new Set(depPaths)]
223
+
224
+ if (uniqueDepPaths.length === 0) {
225
+ logGlobal(
226
+ 'No dependency file changes detected; skipping commit.',
227
+ colors.dim,
228
+ )
229
+ return
230
+ }
231
+
232
+ if (preStatus.length) {
233
+ logGlobal(
234
+ 'Working tree had changes before update; will only stage dependency files for the commit.',
235
+ colors.yellow,
236
+ )
237
+ }
238
+
239
+ logDependencyChanges(uniqueDepPaths, targets)
240
+
241
+ const proposed = buildUpdateCommitMessage(uniqueDepPaths, targets)
242
+ const message = await promptCommitMessage(proposed)
243
+
244
+ logGlobal('Staging dependency changes…', colors.cyan)
245
+ await gitAdd(uniqueDepPaths)
246
+ logGlobal('Creating commit…', colors.cyan)
247
+ await gitCommit(message)
248
+ logGlobal(`Commit created: ${message}`, colors.green)
249
+ logGlobal('Pushing to origin…', colors.cyan)
250
+ await run('git', ['push'])
251
+ logGlobal('Push complete.', colors.green)
252
+ }
253
+
254
+ export async function typecheckAll() {
255
+ logGlobal('Running typecheck for all packages…', colors.cyan)
256
+ await run('pnpm', ['typecheck'])
257
+ }
258
+
259
+ export async function typecheckSingle(pkg: LoadedPackage) {
260
+ const filterArg = pkg.name ?? `./packages/${pkg.dirName}`
261
+ logPkg(pkg, `Running typecheck…`)
262
+ await run('pnpm', ['run', '--filter', filterArg, 'typecheck'])
263
+ }
264
+
265
+ export async function buildAll() {
266
+ logGlobal('Running build for all packages…', colors.cyan)
267
+ await run('pnpm', ['build'])
268
+ }
269
+
270
+ export async function buildSingle(pkg: LoadedPackage) {
271
+ const filterArg = pkg.name ?? `./packages/${pkg.dirName}`
272
+ logPkg(pkg, `Running build…`)
273
+ await run('pnpm', ['run', '--filter', filterArg, 'build'])
274
+ }
275
+
276
+ export async function buildPackageLocally(pkg: LoadedPackage) {
277
+ logPkg(pkg, 'Building local dist before publish…')
278
+ await run('pnpm', ['run', 'build'], { cwd: pkg.path })
279
+ }
280
+
281
+ export async function testAll() {
282
+ logGlobal('Running tests for all packages…', colors.cyan)
283
+ await run('pnpm', ['test'])
284
+ }
285
+
286
+ export async function testSingle(pkg: LoadedPackage) {
287
+ logPkg(pkg, `Running tests…`)
288
+ await run('pnpm', ['test', '--', `packages/${pkg.dirName}`])
289
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "rootDir": "src",
7
+ "outDir": "dist",
8
+ "lib": ["ES2022"],
9
+ "strict": true,
10
+ "esModuleInterop": true,
11
+ "allowSyntheticDefaultImports": true,
12
+ "resolveJsonModule": true,
13
+ "skipLibCheck": true,
14
+ "forceConsistentCasingInFileNames": true,
15
+ "types": ["node"]
16
+ },
17
+ "include": ["src"]
18
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "./tsconfig.base.json"
3
+ }