@emeryld/manager 0.7.10 → 0.8.1

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/dist/menu.js CHANGED
@@ -9,6 +9,7 @@ import { ensureWorkingTreeCommitted } from './preflight.js';
9
9
  import { openDockerHelper } from './docker.js';
10
10
  import { run } from './utils/run.js';
11
11
  import { makeBaseScriptEntries, getPackageMarker, getPackageModule } from './menu/script-helpers.js';
12
+ import { describeVersionControlScope, makeVersionControlEntries, } from './version-control.js';
12
13
  function formatKindLabel(kind) {
13
14
  if (kind === 'cli')
14
15
  return 'CLI';
@@ -43,6 +44,18 @@ function makeManagerStepEntries(targets, packages, state) {
43
44
  state.lastStep = 'rebuild';
44
45
  },
45
46
  },
47
+ {
48
+ name: 'version control',
49
+ emoji: '🧭',
50
+ description: 'Workspace version health, updates, and lockfile checks',
51
+ handler: async () => {
52
+ await runHelperCli({
53
+ title: `Version control for ${describeVersionControlScope(targets, packages)}`,
54
+ scripts: makeVersionControlEntries(targets, packages),
55
+ argv: [],
56
+ });
57
+ },
58
+ },
46
59
  {
47
60
  name: 'test',
48
61
  emoji: '🧪',
package/dist/prompts.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // src/prompts.js
2
2
  import readline from 'node:readline/promises';
3
3
  import { stdin as input, stdout as output } from 'node:process';
4
+ import { colors } from './utils/log.js';
4
5
  export const publishCliState = {
5
6
  autoDecision: undefined,
6
7
  };
@@ -70,22 +71,78 @@ export async function promptYesNoAll(question) {
70
71
  console.log(`${question} (auto-${publishCliState.autoDecision} via "all")`);
71
72
  return publishCliState.autoDecision;
72
73
  }
73
- // Allow yes/no + "apply to all" variants: y, ya, n, na
74
- // Use readline-style input to capture multi-character tokens consistently.
74
+ // Allow yes/no + "apply to all" variants: yy, ya, nn, na
75
+ const promptMessage = `${question} (yy/ya/nn/na): `;
76
+ const supportsRawMode = typeof input.setRawMode === 'function' && input.isTTY;
77
+ const readDecisionInput = async () => {
78
+ if (!supportsRawMode) {
79
+ const fallback = await askLine(promptMessage);
80
+ return fallback.trim().toLowerCase();
81
+ }
82
+ return new Promise((resolve) => {
83
+ const wasRaw = input.isRaw;
84
+ if (!wasRaw) {
85
+ input.setRawMode(true);
86
+ input.resume();
87
+ }
88
+ process.stdout.write(promptMessage);
89
+ const cleanup = () => {
90
+ input.off('data', onData);
91
+ if (!wasRaw) {
92
+ input.setRawMode(false);
93
+ input.pause();
94
+ }
95
+ };
96
+ let collected = '';
97
+ const onData = (buffer) => {
98
+ const str = buffer.toString('utf8');
99
+ for (const char of str) {
100
+ if (char === '\u0003') {
101
+ cleanup();
102
+ process.stdout.write('\n');
103
+ process.exit(1);
104
+ }
105
+ if (char === '\u0008' || char === '\u007f') {
106
+ if (collected.length > 0) {
107
+ collected = collected.slice(0, -1);
108
+ process.stdout.write('\b \b');
109
+ }
110
+ continue;
111
+ }
112
+ if (char === '\r' || char === '\n') {
113
+ continue;
114
+ }
115
+ if (!/^[a-zA-Z]$/.test(char)) {
116
+ continue;
117
+ }
118
+ process.stdout.write(char);
119
+ collected += char;
120
+ if (collected.length === 2) {
121
+ cleanup();
122
+ process.stdout.write('\n');
123
+ resolve(collected.toLowerCase());
124
+ return;
125
+ }
126
+ }
127
+ };
128
+ input.on('data', onData);
129
+ });
130
+ };
75
131
  // eslint-disable-next-line no-constant-condition
76
132
  while (true) {
77
- const answer = (await askLine(`${question} (y/ya/n/na): `)).toLowerCase();
78
- if (answer === 'y')
133
+ const answer = await readDecisionInput();
134
+ if (answer === 'yy')
79
135
  return 'yes';
80
136
  if (answer === 'ya') {
81
137
  publishCliState.autoDecision = 'yes';
82
138
  return 'yes';
83
139
  }
84
- if (answer === 'n')
140
+ if (answer === 'nn')
85
141
  return 'no';
86
142
  if (answer === 'na') {
87
143
  publishCliState.autoDecision = 'no';
88
144
  return 'no';
89
145
  }
146
+ console.log(colors.red('Please enter yy, ya, nn, or na.'));
90
147
  }
91
148
  }
@@ -0,0 +1,230 @@
1
+ import { askLine } from './prompts.js';
2
+ import { run } from './utils/run.js';
3
+ import { colors, logGlobal } from './utils/log.js';
4
+ const DEP_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies'];
5
+ const FIELD_LABELS = {
6
+ dependencies: 'deps',
7
+ devDependencies: 'dev',
8
+ peerDependencies: 'peer',
9
+ };
10
+ function packageLabel(pkg) {
11
+ return pkg.name ?? pkg.dirName;
12
+ }
13
+ function describeVersionControlScope(targets, allPackages) {
14
+ if (targets.length === 0)
15
+ return 'no packages';
16
+ if (targets.length === 1)
17
+ return packageLabel(targets[0]);
18
+ if (targets.length === allPackages.length)
19
+ return 'workspace';
20
+ return `${targets.length} packages`;
21
+ }
22
+ function collectDependencyMap(pkgs) {
23
+ const map = new Map();
24
+ for (const pkg of pkgs) {
25
+ for (const field of DEP_FIELDS) {
26
+ const deps = (pkg.json?.[field] ?? {});
27
+ for (const [name, value] of Object.entries(deps)) {
28
+ if (typeof value !== 'string')
29
+ continue;
30
+ const fieldMap = map.get(name) ?? new Map();
31
+ const entry = { pkg, range: value, field };
32
+ const list = fieldMap.get(value) ?? [];
33
+ list.push(entry);
34
+ fieldMap.set(value, list);
35
+ map.set(name, fieldMap);
36
+ }
37
+ }
38
+ }
39
+ return map;
40
+ }
41
+ function getMostCommonVersion(versions) {
42
+ let best;
43
+ let bestCount = 0;
44
+ for (const [ver, entries] of versions) {
45
+ if (entries.length > bestCount) {
46
+ best = ver;
47
+ bestCount = entries.length;
48
+ }
49
+ }
50
+ return best;
51
+ }
52
+ function hasWorkspaceScope(targets, allPackages, feature) {
53
+ if (allPackages.length === 0) {
54
+ logGlobal('No packages found to run version control actions.', colors.yellow);
55
+ return false;
56
+ }
57
+ if (targets.length !== allPackages.length) {
58
+ logGlobal(`${feature} works across the whole workspace; select "All packages" first.`, colors.yellow);
59
+ return false;
60
+ }
61
+ return true;
62
+ }
63
+ function formatConflictDetails(versions) {
64
+ return [...versions.entries()]
65
+ .map(([range, entries]) => {
66
+ const fields = new Set(entries.map((entry) => FIELD_LABELS[entry.field]));
67
+ const label = [...fields].join('/') || 'deps';
68
+ const packages = entries
69
+ .map((entry) => packageLabel(entry.pkg))
70
+ .join(', ');
71
+ return `${range} (${label}: ${packages})`;
72
+ })
73
+ .join(' | ');
74
+ }
75
+ async function updateByPattern(targets, allPackages) {
76
+ if (!hasWorkspaceScope(targets, allPackages, 'Regex dependency update'))
77
+ return;
78
+ const scopeLabel = describeVersionControlScope(targets, allPackages);
79
+ const defaultPattern = '@emeryld/*';
80
+ const promptPattern = `Dependency pattern (use wildcards or scopes, default ${defaultPattern}): `;
81
+ const patternInput = await askLine(promptPattern);
82
+ const rawPattern = (patternInput ?? '').trim() || defaultPattern;
83
+ const promptVersion = 'Version or tag to pin (default "latest"): ';
84
+ const versionInput = await askLine(promptVersion);
85
+ const normalizedVersion = (versionInput ?? '').trim() || 'latest';
86
+ const lastAt = rawPattern.lastIndexOf('@');
87
+ const slashIndex = rawPattern.lastIndexOf('/');
88
+ const hasExplicitVersion = lastAt > 0 && lastAt > slashIndex;
89
+ const spec = hasExplicitVersion
90
+ ? rawPattern
91
+ : `${rawPattern}@${normalizedVersion}`;
92
+ logGlobal(`Updating dependencies using ${colors.cyan(spec)} for ${scopeLabel}…`, colors.cyan);
93
+ await run('pnpm', ['update', '-r', spec]);
94
+ logGlobal('Pattern update complete.', colors.green);
95
+ }
96
+ async function runVersionHealthScan(targets, allPackages) {
97
+ if (!hasWorkspaceScope(targets, allPackages, 'Version health scan'))
98
+ return;
99
+ const scopeLabel = describeVersionControlScope(targets, allPackages);
100
+ logGlobal(`Scanning version misalignments for ${scopeLabel}…`, colors.cyan);
101
+ const dependencyMap = collectDependencyMap(allPackages);
102
+ const conflicts = [...dependencyMap.entries()].filter(([, versions]) => versions.size > 1);
103
+ if (conflicts.length === 0) {
104
+ logGlobal('No version conflicts detected.', colors.green);
105
+ return;
106
+ }
107
+ console.log(colors.yellow('Misaligned dependency versions:'));
108
+ conflicts.forEach(([dep, versions]) => {
109
+ console.log(` ${colors.bold(dep)} → ${formatConflictDetails(versions)}`);
110
+ });
111
+ }
112
+ async function alignDependencyVersions(targets, allPackages) {
113
+ if (!hasWorkspaceScope(targets, allPackages, 'Dependency alignment'))
114
+ return;
115
+ const scopeLabel = describeVersionControlScope(targets, allPackages);
116
+ const dependencyMap = collectDependencyMap(allPackages);
117
+ if (!dependencyMap.size) {
118
+ logGlobal('No dependencies found in the workspace.', colors.yellow);
119
+ return;
120
+ }
121
+ const misaligned = [...dependencyMap.entries()]
122
+ .filter(([, versions]) => versions.size > 1)
123
+ .map(([dep]) => dep);
124
+ const defaultDependency = misaligned[0] ?? dependencyMap.keys().next().value ?? '';
125
+ if (!defaultDependency) {
126
+ logGlobal('Unable to infer a dependency to align.', colors.yellow);
127
+ return;
128
+ }
129
+ const dependencyPrompt = `Dependency to align (default ${defaultDependency}): `;
130
+ const dependencyInput = (await askLine(dependencyPrompt))?.trim();
131
+ const dependency = dependencyInput || defaultDependency;
132
+ const versions = dependencyMap.get(dependency);
133
+ if (!versions) {
134
+ logGlobal(`Dependency "${dependency}" not tracked across the workspace.`, colors.yellow);
135
+ return;
136
+ }
137
+ const defaultVersion = getMostCommonVersion(versions) ?? [...versions.keys()][0];
138
+ const versionPrompt = defaultVersion && defaultVersion.length
139
+ ? `Version to align to (default ${defaultVersion}): `
140
+ : 'Version to align to: ';
141
+ const versionInputFinal = (await askLine(versionPrompt))?.trim();
142
+ const targetVersion = versionInputFinal || defaultVersion;
143
+ if (!targetVersion) {
144
+ logGlobal('No version selected; cancelling alignment.', colors.yellow);
145
+ return;
146
+ }
147
+ const spec = `${dependency}@${targetVersion}`;
148
+ logGlobal(`Aligning ${colors.cyan(dependency)} to ${colors.cyan(targetVersion)} across ${scopeLabel}…`, colors.cyan);
149
+ await run('pnpm', ['update', '-r', spec]);
150
+ logGlobal('Dependency alignment complete.', colors.green);
151
+ }
152
+ async function showDriftReport(targets, allPackages) {
153
+ if (!hasWorkspaceScope(targets, allPackages, 'Drift report'))
154
+ return;
155
+ const scopeLabel = describeVersionControlScope(targets, allPackages);
156
+ logGlobal(`Computing drift report for ${scopeLabel}…`, colors.cyan);
157
+ const dependencyMap = collectDependencyMap(allPackages);
158
+ const driftLines = [];
159
+ for (const [dep, versions] of dependencyMap.entries()) {
160
+ if (versions.size <= 1)
161
+ continue;
162
+ const standard = getMostCommonVersion(versions);
163
+ const deviators = [...versions.entries()].filter(([range]) => range !== standard);
164
+ if (!deviators.length)
165
+ continue;
166
+ const detail = deviators
167
+ .map(([range, entries]) => `${range}: ${entries.map((entry) => packageLabel(entry.pkg)).join(', ')}`)
168
+ .join(' | ');
169
+ driftLines.push(`${colors.bold(dep)} (standard ${standard}) → ${detail}`);
170
+ }
171
+ if (!driftLines.length) {
172
+ logGlobal('No drift detected; shared dependencies are aligned.', colors.green);
173
+ return;
174
+ }
175
+ console.log(colors.yellow('Drifting dependencies:'));
176
+ driftLines.forEach((line) => console.log(` ${line}`));
177
+ }
178
+ async function checkLockfile(targets, allPackages) {
179
+ if (!hasWorkspaceScope(targets, allPackages, 'Lockfile check'))
180
+ return;
181
+ const scopeLabel = describeVersionControlScope(targets, allPackages);
182
+ logGlobal(`Verifying lockfile matches the ${scopeLabel} manifests…`, colors.cyan);
183
+ await run('pnpm', ['install', '--frozen-lockfile', '--ignore-scripts']);
184
+ logGlobal('Lockfile verification succeeded.', colors.green);
185
+ }
186
+ export function makeVersionControlEntries(targets, allPackages) {
187
+ return [
188
+ {
189
+ name: 'regex dependency update',
190
+ emoji: '🧷',
191
+ description: 'Update all workspace packages matching a pattern to a version/tag',
192
+ handler: async () => {
193
+ await updateByPattern(targets, allPackages);
194
+ },
195
+ },
196
+ {
197
+ name: 'version health scan',
198
+ emoji: '🩺',
199
+ description: 'Flag conflicting dependency versions across the workspace',
200
+ handler: async () => {
201
+ await runVersionHealthScan(targets, allPackages);
202
+ },
203
+ },
204
+ {
205
+ name: 'dependency alignment',
206
+ emoji: '🎯',
207
+ description: 'Pick a dependency and pin every workspace package to the same version',
208
+ handler: async () => {
209
+ await alignDependencyVersions(targets, allPackages);
210
+ },
211
+ },
212
+ {
213
+ name: 'drift & outdated report',
214
+ emoji: '📉',
215
+ description: 'Summarize distances from the standard version for shared deps',
216
+ handler: async () => {
217
+ await showDriftReport(targets, allPackages);
218
+ },
219
+ },
220
+ {
221
+ name: 'lockfile consistency check',
222
+ emoji: '🧰',
223
+ description: 'Ensure pnpm-lock.yaml stays compatible with the manifest',
224
+ handler: async () => {
225
+ await checkLockfile(targets, allPackages);
226
+ },
227
+ },
228
+ ];
229
+ }
230
+ export { describeVersionControlScope };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emeryld/manager",
3
- "version": "0.7.10",
3
+ "version": "0.8.1",
4
4
  "description": "Interactive manager for pnpm monorepos (update/test/build/publish).",
5
5
  "license": "MIT",
6
6
  "type": "module",