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/LICENSE +21 -0
- package/README.md +216 -0
- package/package.json +41 -0
- package/src/commands/add.js +45 -0
- package/src/commands/branch.js +54 -0
- package/src/commands/init.js +95 -0
- package/src/commands/pull.js +65 -0
- package/src/commands/push.js +90 -0
- package/src/commands/status.js +29 -0
- package/src/git.js +250 -0
- package/src/index.js +150 -0
- package/src/templates.js +128 -0
- package/src/ui.js +218 -0
- package/src/validate.js +85 -0
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
|
+
}
|
package/src/validate.js
ADDED
|
@@ -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';
|