unirepo-cli 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.
package/src/ui.js ADDED
@@ -0,0 +1,218 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import chalk from 'chalk';
3
+
4
+ const PACKAGE_JSON = JSON.parse(
5
+ readFileSync(new URL('../package.json', import.meta.url), 'utf8')
6
+ );
7
+
8
+ // ── Icons ──────────────────────────────────────────────────────────────────────
9
+
10
+ const ICON = {
11
+ check: chalk.green('✔'),
12
+ cross: chalk.red('✖'),
13
+ arrow: chalk.cyan('→'),
14
+ warn: chalk.yellow('⚠'),
15
+ folder: chalk.blue('📁'),
16
+ branch: chalk.magenta('⎇'),
17
+ rocket: chalk.yellow('🚀'),
18
+ search: chalk.cyan('🔍'),
19
+ git: chalk.red('⬡'),
20
+ package: chalk.green('📦'),
21
+ dot: chalk.dim('·'),
22
+ };
23
+
24
+ // ── Core Output ────────────────────────────────────────────────────────────────
25
+
26
+ export function header(text) {
27
+ console.log();
28
+ console.log(chalk.bold.cyan(` ${ICON.git} ${text}`));
29
+ console.log(chalk.dim(' ' + '─'.repeat(50)));
30
+ }
31
+
32
+ export function step(n, total, text) {
33
+ const prefix = chalk.dim(` [${n}/${total}]`);
34
+ console.log(`${prefix} ${text}`);
35
+ }
36
+
37
+ export function success(text) {
38
+ console.log(` ${ICON.check} ${chalk.green(text)}`);
39
+ }
40
+
41
+ export function error(text) {
42
+ console.log(` ${ICON.cross} ${chalk.red(text)}`);
43
+ }
44
+
45
+ export function warning(text) {
46
+ console.log(` ${ICON.warn} ${chalk.yellow(text)}`);
47
+ }
48
+
49
+ export function info(text) {
50
+ console.log(` ${chalk.dim(text)}`);
51
+ }
52
+
53
+ export function blank() {
54
+ console.log();
55
+ }
56
+
57
+ // ── Specialized Output ─────────────────────────────────────────────────────────
58
+
59
+ export function repoStep(n, total, name, action) {
60
+ const prefix = chalk.dim(` [${n}/${total}]`);
61
+ const repoName = chalk.bold.white(name);
62
+ console.log(`${prefix} ${action} ${repoName}`);
63
+ }
64
+
65
+ export function repoDetail(label, value) {
66
+ console.log(` ${chalk.dim(label + ':')} ${value}`);
67
+ }
68
+
69
+ export function subtreeTable(subtrees, currentBranch) {
70
+ header('Monorepo Status');
71
+ blank();
72
+ console.log(` ${ICON.folder} Subtrees: ${chalk.bold(subtrees.length)}`);
73
+ console.log(` ${ICON.rocket} Push branch: ${chalk.bold.cyan(currentBranch)}`);
74
+ blank();
75
+
76
+ if (subtrees.length === 0) {
77
+ info('No subtrees found. Use "unirepo add" to add repos.');
78
+ blank();
79
+ return;
80
+ }
81
+
82
+ // Column widths
83
+ const nameWidth = Math.max(12, ...subtrees.map((s) => s.name.length)) + 2;
84
+ const upstreamWidth = Math.max(10, ...subtrees.map((s) => (s.upstream || '').length)) + 2;
85
+ const pushWidth = Math.max(12, currentBranch.length) + 2;
86
+ const urlWidth = Math.max(10, ...subtrees.map((s) => s.url.length)) + 2;
87
+ const headerRow =
88
+ 'Subtree'.padEnd(nameWidth) +
89
+ 'Upstream'.padEnd(upstreamWidth) +
90
+ 'Push branch'.padEnd(pushWidth) +
91
+ 'Remote URL'.padEnd(urlWidth) +
92
+ 'Changed';
93
+
94
+ console.log(chalk.dim(` ${headerRow}`));
95
+ console.log(chalk.dim(' ' + '─'.repeat(headerRow.length)));
96
+
97
+ for (const s of subtrees) {
98
+ const changed = s.changed
99
+ ? chalk.yellow('● yes')
100
+ : chalk.dim('○ no');
101
+ const upstreamStr = chalk.dim((s.upstream || 'unknown').padEnd(upstreamWidth));
102
+ const pushStr = chalk.cyan(currentBranch.padEnd(pushWidth));
103
+ console.log(
104
+ ` ${chalk.bold(s.name.padEnd(nameWidth))}${upstreamStr}${pushStr}${chalk.dim(s.url.padEnd(urlWidth))}${changed}`
105
+ );
106
+ }
107
+ blank();
108
+ }
109
+
110
+ export function dryRun(actions) {
111
+ console.log();
112
+ console.log(` ${ICON.warn} ${chalk.yellow.bold('Dry run')} — these commands would execute:`);
113
+ console.log();
114
+ for (const action of actions) {
115
+ console.log(` ${chalk.dim('$')} ${chalk.white(action)}`);
116
+ }
117
+ console.log();
118
+ }
119
+
120
+ export function pushStart(name, branch) {
121
+ console.log(` ${ICON.rocket} Pushing ${chalk.bold(name)} ${ICON.arrow} ${chalk.cyan(branch)}`);
122
+ }
123
+
124
+ export function pushSlow() {
125
+ info(' Subtree push walks commit history — this may take a moment...');
126
+ }
127
+
128
+ export function initSummary(dir, count, subtreeNames) {
129
+ blank();
130
+ console.log(chalk.dim(' ' + '─'.repeat(50)));
131
+ console.log(` ${ICON.package} ${chalk.green.bold('Monorepo created successfully!')}`);
132
+ console.log(` ${ICON.folder} Location: ${chalk.white(dir)}`);
133
+ console.log(` ${ICON.git} Subtrees: ${chalk.white(count)}`);
134
+ blank();
135
+ if (subtreeNames && subtreeNames.length > 0) {
136
+ console.log(chalk.bold(' Next steps:'));
137
+ console.log(` ${chalk.dim('$')} cd ${dir.includes(' ') ? `"${dir}"` : dir}`);
138
+ console.log(` ${chalk.dim('$')} unirepo status`);
139
+ console.log(` ${chalk.dim('# edit files in')} ${subtreeNames.map(n => chalk.cyan(n + '/')).join(', ')}`);
140
+ console.log(` ${chalk.dim('$')} git add . && git commit -m "feat: ..."`);
141
+ console.log(` ${chalk.dim('$')} unirepo push --dry-run`);
142
+ blank();
143
+ }
144
+ }
145
+
146
+ export function addSummary(name, url) {
147
+ blank();
148
+ success(`Added ${chalk.bold(name)} from ${chalk.dim(url)}`);
149
+ blank();
150
+ }
151
+
152
+ export function version() {
153
+ console.log(`unirepo ${PACKAGE_JSON.version}`);
154
+ }
155
+
156
+ // ── Help / Usage ───────────────────────────────────────────────────────────────
157
+
158
+ export function usage() {
159
+ console.log(`
160
+ ${chalk.bold.cyan('unirepo')} — create and manage git-subtree monorepos
161
+ ${chalk.dim(`Version: ${PACKAGE_JSON.version}`)}
162
+
163
+ ${chalk.bold('Usage:')}
164
+ unirepo ${chalk.green('<command>')} [options]
165
+
166
+ ${chalk.bold('Commands:')}
167
+ ${chalk.green('init')} <dir> <repo-url...> Create a new monorepo from repo URLs
168
+ ${chalk.green('add')} <repo-url> Add a repo to the current monorepo
169
+ ${chalk.green('pull')} [subtree...] Pull subtree updates from upstream
170
+ ${chalk.green('status')} Show tracked subtrees and changes
171
+ ${chalk.green('push')} [subtree...] Push changed subtrees upstream
172
+ ${chalk.green('branch')} [name] Create a branch on all upstream repos
173
+ ${chalk.green('version')} Show the installed CLI version
174
+
175
+ ${chalk.bold('Global options:')}
176
+ --help, -h Show help
177
+ --version, -v Show the installed CLI version
178
+
179
+ ${chalk.bold('Init options:')}
180
+ --full-history Import full git history (default: shallow + squash)
181
+
182
+ ${chalk.bold('Add options:')}
183
+ --prefix <name> Override the subtree directory name
184
+ --branch <name> Import from a specific upstream branch
185
+ --full-history Import full git history
186
+
187
+ ${chalk.bold('Pull options:')}
188
+ --branch <name> Pull a specific upstream branch for all selected subtrees
189
+ --full-history Pull full history instead of squash mode
190
+
191
+ ${chalk.bold('Status options:')}
192
+ --json Output machine-readable JSON
193
+
194
+ ${chalk.bold('Push options:')}
195
+ --branch <name> Branch name for upstream push (default: current)
196
+ --dry-run Show commands without executing
197
+
198
+ ${chalk.bold('Examples:')}
199
+ ${chalk.dim('# Create monorepo from multiple repos')}
200
+ npx unirepo init my-monorepo https://github.com/org/api.git https://github.com/org/web.git
201
+
202
+ ${chalk.dim('# Add another repo later')}
203
+ cd my-monorepo
204
+ npx unirepo add https://github.com/org/shared.git --branch main
205
+
206
+ ${chalk.dim('# Pull upstream updates before working')}
207
+ npx unirepo pull
208
+
209
+ ${chalk.dim('# Check status')}
210
+ npx unirepo status
211
+
212
+ ${chalk.dim('# Create a branch (used as target when pushing all subtrees)')}
213
+ npx unirepo branch feature-x
214
+
215
+ ${chalk.dim('# Push changes upstream')}
216
+ npx unirepo push --dry-run
217
+ `);
218
+ }
@@ -0,0 +1,85 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { hasSubtreeCommand, isInsideWorkTree, getRemotes, getSubtreePrefixes, extractRepoName } from './git.js';
5
+
6
+ export function validateGitSubtree() {
7
+ if (!hasSubtreeCommand()) {
8
+ throw new Error(
9
+ 'git subtree is not available. It is usually bundled with git — check your git installation.'
10
+ );
11
+ }
12
+ }
13
+
14
+ export function validateUrls(urls) {
15
+ for (const url of urls) {
16
+ const looksValid =
17
+ url.startsWith('https://') ||
18
+ url.startsWith('http://') ||
19
+ url.startsWith('git@') ||
20
+ url.startsWith('ssh://') ||
21
+ url.startsWith('git://');
22
+ if (!looksValid) {
23
+ throw new Error(`Invalid repository URL: ${url}`);
24
+ }
25
+ }
26
+ }
27
+
28
+ export function validateNoDuplicateNames(urls) {
29
+ const names = urls.map((u) => extractRepoName(u));
30
+ const seen = new Set();
31
+ for (const name of names) {
32
+ if (!name) {
33
+ throw new Error(`Could not extract repository name from URL`);
34
+ }
35
+ if (seen.has(name)) {
36
+ throw new Error(
37
+ `Duplicate repository name "${name}". Use different repos or rename before importing.`
38
+ );
39
+ }
40
+ seen.add(name);
41
+ }
42
+ }
43
+
44
+ export function validateInsideMonorepo(cwd) {
45
+ if (!isInsideWorkTree(cwd)) {
46
+ throw new Error(
47
+ 'Not inside a git repository. Run this command from a monorepo created with "unirepo init".'
48
+ );
49
+ }
50
+ const prefixes = getSubtreePrefixes(cwd);
51
+ if (prefixes.length === 0) {
52
+ const remotes = getRemotes(cwd);
53
+ if (remotes.length === 0) {
54
+ throw new Error(
55
+ 'No remotes found. This does not look like a subtree monorepo.'
56
+ );
57
+ }
58
+ }
59
+ }
60
+
61
+ export function validateNameAvailable(name, cwd) {
62
+ const remotes = getRemotes(cwd);
63
+ if (remotes.some((r) => r.name === name)) {
64
+ throw new Error(`Remote "${name}" already exists. Choose a different prefix or remove the existing remote.`);
65
+ }
66
+ if (existsSync(join(cwd, name))) {
67
+ throw new Error(`Directory "${name}" already exists. Choose a different prefix.`);
68
+ }
69
+ }
70
+
71
+ export function validateReachable(url) {
72
+ try {
73
+ execSync(`git ls-remote --exit-code "${url}" HEAD`, {
74
+ encoding: 'utf-8',
75
+ stdio: ['pipe', 'pipe', 'pipe'],
76
+ timeout: 15000,
77
+ });
78
+ } catch {
79
+ throw new Error(
80
+ `Cannot reach repository: ${url}\n Check the URL and your access permissions.`
81
+ );
82
+ }
83
+ }
84
+
85
+ export { extractRepoName } from './git.js';