unirepo-cli 0.1.0 → 0.2.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/README.md +90 -73
- package/package.json +3 -1
- package/src/github.js +113 -0
- package/src/index.js +13 -0
- package/src/interactive.js +342 -0
- package/src/ui.js +10 -0
package/README.md
CHANGED
|
@@ -4,63 +4,102 @@
|
|
|
4
4
|
[](https://github.com/Poko18/unirepo/actions/workflows/ci.yml)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
### One workspace to refactor your whole stack in one go
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Work across multiple repos like they’re one. Unirepo turns multiple GitHub repositories into a single unified workspace. Edit backend, frontend, and shared code in one tree, commit from one place, and push changes back to each repo using the same branch.
|
|
10
10
|
|
|
11
|
-
Built for
|
|
11
|
+
### Built for AI coding agents
|
|
12
12
|
|
|
13
|
+
AI agents need full context to make correct changes—not one repo at a time. Unirepo gives them a single workspace to refactor your entire stack, keep changes consistent, and push updates back to each repo.
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
### Example task
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
You need to update:
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
~/work/shared ← clone, checkout same branch, edit
|
|
22
|
-
# remember to push all three, open three PRs
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
**With unirepo:**
|
|
26
|
-
|
|
27
|
-
```
|
|
28
|
-
~/work/my-workspace/
|
|
29
|
-
├── api/ ← edit
|
|
30
|
-
├── web/ ← edit
|
|
31
|
-
└── shared/ ← edit
|
|
32
|
-
# one commit, one push — all three repos updated
|
|
33
|
-
```
|
|
19
|
+
- `api/` for a new endpoint
|
|
20
|
+
- `web/` for the UI
|
|
21
|
+
- `shared/` for shared types
|
|
34
22
|
|
|
23
|
+
<table>
|
|
24
|
+
<tr>
|
|
25
|
+
<td width="50%" valign="top">
|
|
35
26
|
|
|
36
|
-
|
|
27
|
+
**Without `unirepo`**
|
|
37
28
|
|
|
38
29
|
```bash
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
30
|
+
# clone and branch in each repo
|
|
31
|
+
git clone git@github.com:org/api.git
|
|
32
|
+
git clone git@github.com:org/web.git
|
|
33
|
+
git clone git@github.com:org/shared.git
|
|
34
|
+
|
|
35
|
+
cd api
|
|
36
|
+
git checkout -b feature-auth
|
|
37
|
+
cd ../web
|
|
38
|
+
git checkout -b feature-auth
|
|
39
|
+
cd ../shared
|
|
40
|
+
git checkout -b feature-auth
|
|
41
|
+
|
|
42
|
+
# edit in 3 separate checkouts
|
|
43
|
+
|
|
44
|
+
# commit in each repo
|
|
45
|
+
cd ../api
|
|
46
|
+
git add .
|
|
47
|
+
git commit -m "feat: add auth"
|
|
48
|
+
cd ../web
|
|
49
|
+
git add .
|
|
50
|
+
git commit -m "feat: add auth UI"
|
|
51
|
+
cd ../shared
|
|
52
|
+
git add .
|
|
53
|
+
git commit -m "feat: add auth types"
|
|
54
|
+
|
|
55
|
+
# push from each repo
|
|
56
|
+
cd ../api
|
|
57
|
+
git push -u origin feature-auth
|
|
58
|
+
cd ../web
|
|
59
|
+
git push -u origin feature-auth
|
|
60
|
+
cd ../shared
|
|
61
|
+
git push -u origin feature-auth
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
</td>
|
|
65
|
+
<td width="50%" valign="top">
|
|
66
|
+
|
|
67
|
+
**With `unirepo`**
|
|
43
68
|
|
|
44
|
-
|
|
69
|
+
```bash
|
|
70
|
+
# create one workspace and one branch
|
|
71
|
+
unirepo init my-workspace <repo...>
|
|
72
|
+
cd my-workspace
|
|
73
|
+
unirepo branch feature-auth
|
|
45
74
|
|
|
46
|
-
|
|
47
|
-
my-workspace/
|
|
48
|
-
├── api/ ← from github.com/org/api
|
|
49
|
-
├── web/ ← from github.com/org/web
|
|
50
|
-
├── AGENTS.md ← workflow guide for humans and agents
|
|
51
|
-
└── .gitignore
|
|
52
|
-
```
|
|
75
|
+
# edit api/, web/, and shared/ together
|
|
53
76
|
|
|
54
|
-
|
|
77
|
+
# commit once from the workspace
|
|
78
|
+
git add .
|
|
79
|
+
git commit -m "feat: add auth flow"
|
|
55
80
|
|
|
56
|
-
|
|
57
|
-
cd my-workspace
|
|
58
|
-
# edit files in api/ and web/
|
|
59
|
-
git add . && git commit -m "feat: update shared types"
|
|
81
|
+
# push changed subtrees
|
|
60
82
|
unirepo push
|
|
61
83
|
```
|
|
62
84
|
|
|
63
|
-
|
|
85
|
+
</td>
|
|
86
|
+
</tr>
|
|
87
|
+
</table>
|
|
88
|
+
|
|
89
|
+
## Same workflow, fewer commands
|
|
90
|
+
|
|
91
|
+
`unirepo` keeps your upstream repos separate while removing:
|
|
92
|
+
|
|
93
|
+
- Repo and branch juggling
|
|
94
|
+
- Repeated setup and commands
|
|
95
|
+
- Context switching
|
|
96
|
+
- Inconsistent cross-repo changes
|
|
97
|
+
|
|
98
|
+
It enables:
|
|
99
|
+
|
|
100
|
+
- One place to see the full change
|
|
101
|
+
- Safe cross-repo refactors
|
|
102
|
+
- A single workspace for humans and AI
|
|
64
103
|
|
|
65
104
|
|
|
66
105
|
## Install
|
|
@@ -82,6 +121,17 @@ npx unirepo-cli <command>
|
|
|
82
121
|
```
|
|
83
122
|
|
|
84
123
|
|
|
124
|
+
## Why it works well for agents
|
|
125
|
+
|
|
126
|
+
AI coding agents work best when they can see the full change at once.
|
|
127
|
+
|
|
128
|
+
- Update backend, frontend, and shared contracts in one pass
|
|
129
|
+
- Commit coordinated changes from one repo
|
|
130
|
+
- Check affected subtrees with `unirepo status`
|
|
131
|
+
- Push only changed subtrees with `unirepo push`
|
|
132
|
+
- Reuse the generated `AGENTS.md` workflow guide
|
|
133
|
+
|
|
134
|
+
|
|
85
135
|
## Commands
|
|
86
136
|
|
|
87
137
|
| Command | Description |
|
|
@@ -92,40 +142,7 @@ npx unirepo-cli <command>
|
|
|
92
142
|
| `status` | Show subtrees, branches, and what changed |
|
|
93
143
|
| `branch [name]` | Create or show the current push branch |
|
|
94
144
|
| `push [subtree...]` | Push changed subtrees upstream |
|
|
95
|
-
| `version` | Show CLI version |
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
## Workflow
|
|
99
|
-
|
|
100
|
-
A typical session looks like this:
|
|
101
|
-
|
|
102
|
-
```bash
|
|
103
|
-
# 1. Create a branch for your work
|
|
104
|
-
unirepo branch feature-auth
|
|
105
|
-
|
|
106
|
-
# 2. Pull latest upstream changes
|
|
107
|
-
unirepo pull
|
|
108
|
-
|
|
109
|
-
# 3. Edit files across repos
|
|
110
|
-
vim api/src/auth.js web/src/login.tsx
|
|
111
|
-
|
|
112
|
-
# 4. Commit in the monorepo
|
|
113
|
-
git add . && git commit -m "feat: add OAuth flow"
|
|
114
|
-
|
|
115
|
-
# 5. Check what will be pushed
|
|
116
|
-
unirepo status
|
|
117
|
-
unirepo push --dry-run
|
|
118
|
-
|
|
119
|
-
# 6. Push to upstream repos
|
|
120
|
-
unirepo push
|
|
121
|
-
|
|
122
|
-
# 7. Open one PR per upstream repo
|
|
123
|
-
```
|
|
124
|
-
|
|
125
|
-
The branch name you create in the workspace is reused when pushing to each upstream repo.
|
|
126
|
-
|
|
127
145
|
|
|
128
|
-
## Command Reference
|
|
129
146
|
|
|
130
147
|
### init
|
|
131
148
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unirepo-cli",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "CLI tool for creating and managing git-subtree monorepos — run your agents across repos",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -36,6 +36,8 @@
|
|
|
36
36
|
"author": "Poko18",
|
|
37
37
|
"license": "MIT",
|
|
38
38
|
"dependencies": {
|
|
39
|
+
"@inquirer/core": "^10.0.0",
|
|
40
|
+
"@inquirer/prompts": "^7.0.0",
|
|
39
41
|
"chalk": "^5.3.0"
|
|
40
42
|
}
|
|
41
43
|
}
|
package/src/github.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Thin wrapper around the `gh` CLI. We shell out rather than hitting the
|
|
5
|
+
* GitHub API directly so we reuse whatever auth the user already has
|
|
6
|
+
* configured (`gh auth login`) — no token juggling.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const REPO_FIELDS = 'nameWithOwner,url,pushedAt,description,isPrivate';
|
|
10
|
+
const DEFAULT_LIMIT = 200;
|
|
11
|
+
const SEARCH_LIMIT = 30;
|
|
12
|
+
|
|
13
|
+
function runGh(args, { timeout = 30000 } = {}) {
|
|
14
|
+
try {
|
|
15
|
+
return execSync(`gh ${args}`, {
|
|
16
|
+
encoding: 'utf-8',
|
|
17
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
18
|
+
timeout,
|
|
19
|
+
});
|
|
20
|
+
} catch (err) {
|
|
21
|
+
const stderr = (err.stderr || '').trim();
|
|
22
|
+
throw new Error(stderr || err.message);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function runGhJson(args, opts) {
|
|
27
|
+
const out = runGh(args, opts);
|
|
28
|
+
if (!out.trim()) return [];
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(out);
|
|
31
|
+
} catch (err) {
|
|
32
|
+
throw new Error(`Failed to parse gh output: ${err.message}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Check whether `gh` is installed AND the user is authenticated.
|
|
38
|
+
* Returns false (not throws) on either failure so callers can branch cleanly.
|
|
39
|
+
*/
|
|
40
|
+
export function isGhAvailable() {
|
|
41
|
+
try {
|
|
42
|
+
execSync('gh --version', { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
execSync('gh auth status', { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
48
|
+
return true;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Get the authenticated user's login (for display in the source menu).
|
|
56
|
+
* Returns null on failure.
|
|
57
|
+
*/
|
|
58
|
+
export function getAuthenticatedUser() {
|
|
59
|
+
try {
|
|
60
|
+
const out = runGh('api user --jq .login');
|
|
61
|
+
return out.trim() || null;
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* List repositories owned by the authenticated user. `gh repo list` returns
|
|
69
|
+
* results roughly in recently-updated order by default (no --sort flag).
|
|
70
|
+
*/
|
|
71
|
+
export function listPersonalRepos(limit = DEFAULT_LIMIT) {
|
|
72
|
+
return runGhJson(`repo list --json ${REPO_FIELDS} --limit ${limit}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* List organizations the authenticated user belongs to.
|
|
77
|
+
* Returns an array of `{ login }` objects.
|
|
78
|
+
*/
|
|
79
|
+
export function listOrgs() {
|
|
80
|
+
try {
|
|
81
|
+
const out = runGh('api user/orgs --jq "[.[] | {login: .login}]"');
|
|
82
|
+
return JSON.parse(out);
|
|
83
|
+
} catch {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* List repositories for a specific organization.
|
|
90
|
+
*/
|
|
91
|
+
export function listOrgRepos(org, limit = DEFAULT_LIMIT) {
|
|
92
|
+
return runGhJson(`repo list ${org} --json ${REPO_FIELDS} --limit ${limit}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Search GitHub repos by a free-text query.
|
|
97
|
+
*/
|
|
98
|
+
export function searchRepos(query, limit = SEARCH_LIMIT) {
|
|
99
|
+
// gh search repos uses `url` + `name` + `fullName`, not `nameWithOwner`.
|
|
100
|
+
// Normalize to the same shape the other listers return.
|
|
101
|
+
const fields = 'fullName,url,pushedAt,description,isPrivate';
|
|
102
|
+
const escaped = query.replace(/"/g, '\\"');
|
|
103
|
+
const raw = runGhJson(
|
|
104
|
+
`search repos "${escaped}" --json ${fields} --limit ${limit}`
|
|
105
|
+
);
|
|
106
|
+
return raw.map((r) => ({
|
|
107
|
+
nameWithOwner: r.fullName,
|
|
108
|
+
url: r.url,
|
|
109
|
+
pushedAt: r.pushedAt,
|
|
110
|
+
description: r.description,
|
|
111
|
+
isPrivate: r.isPrivate,
|
|
112
|
+
}));
|
|
113
|
+
}
|
package/src/index.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import * as ui from './ui.js';
|
|
4
4
|
import { runInit } from './commands/init.js';
|
|
5
|
+
import { runInteractiveInit, CancelError } from './interactive.js';
|
|
5
6
|
import { runAdd } from './commands/add.js';
|
|
6
7
|
import { runPull } from './commands/pull.js';
|
|
7
8
|
import { runStatus } from './commands/status.js';
|
|
@@ -70,8 +71,15 @@ export async function main() {
|
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
case 'init': {
|
|
74
|
+
if (positional.length === 0) {
|
|
75
|
+
// No args → interactive setup flow
|
|
76
|
+
const result = await runInteractiveInit();
|
|
77
|
+
await runInit(result);
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
73
80
|
if (positional.length < 2) {
|
|
74
81
|
ui.error('Usage: unirepo init <dir> <repo-url> [repo-url...]');
|
|
82
|
+
ui.info('Or run "unirepo init" with no arguments for interactive setup.');
|
|
75
83
|
process.exit(1);
|
|
76
84
|
}
|
|
77
85
|
const [dir, ...repos] = positional;
|
|
@@ -144,6 +152,11 @@ const isDirectRun = isDirectRunCheck();
|
|
|
144
152
|
|
|
145
153
|
if (isDirectRun) {
|
|
146
154
|
main().catch((err) => {
|
|
155
|
+
if (err instanceof CancelError) {
|
|
156
|
+
ui.blank();
|
|
157
|
+
ui.info(err.message);
|
|
158
|
+
process.exit(0);
|
|
159
|
+
}
|
|
147
160
|
ui.error(err.message);
|
|
148
161
|
process.exit(1);
|
|
149
162
|
});
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { input, select, confirm, Separator } from '@inquirer/prompts';
|
|
2
|
+
import {
|
|
3
|
+
createPrompt,
|
|
4
|
+
useState,
|
|
5
|
+
useKeypress,
|
|
6
|
+
usePagination,
|
|
7
|
+
usePrefix,
|
|
8
|
+
makeTheme,
|
|
9
|
+
isUpKey,
|
|
10
|
+
isDownKey,
|
|
11
|
+
isEnterKey,
|
|
12
|
+
} from '@inquirer/core';
|
|
13
|
+
import chalk from 'chalk';
|
|
14
|
+
import * as ui from './ui.js';
|
|
15
|
+
import * as gh from './github.js';
|
|
16
|
+
import { validateUrls, validateReachable } from './validate.js';
|
|
17
|
+
import { extractRepoName } from './git.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Sentinel thrown when the user cancels the flow (Ctrl+C, "Cancel" choice,
|
|
21
|
+
* or answering "no" at the final confirm). Caught by index.js so we exit
|
|
22
|
+
* quietly instead of printing a stack trace.
|
|
23
|
+
*/
|
|
24
|
+
export class CancelError extends Error {
|
|
25
|
+
constructor(message = 'Setup cancelled.') {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = 'CancelError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Entry point for `unirepo init` when no positional args are given.
|
|
33
|
+
*/
|
|
34
|
+
export async function runInteractiveInit() {
|
|
35
|
+
ui.interactiveHeader();
|
|
36
|
+
|
|
37
|
+
const ghOk = gh.isGhAvailable();
|
|
38
|
+
const user = ghOk ? gh.getAuthenticatedUser() : null;
|
|
39
|
+
const orgs = ghOk ? safeListOrgs() : [];
|
|
40
|
+
|
|
41
|
+
if (!ghOk) {
|
|
42
|
+
ui.warning('GitHub CLI (gh) not detected or not authenticated.');
|
|
43
|
+
ui.info('Install: https://cli.github.com/ then run: gh auth login');
|
|
44
|
+
ui.info('Falling back to manual URL entry.');
|
|
45
|
+
ui.blank();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** @type {Map<string, {nameWithOwner: string, url: string, isPrivate?: boolean}>} */
|
|
49
|
+
const selected = new Map();
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
while (true) {
|
|
53
|
+
const action = await select({
|
|
54
|
+
message: 'Add repos from',
|
|
55
|
+
choices: buildSourceMenu({ ghOk, user, orgs, count: selected.size }),
|
|
56
|
+
pageSize: 10,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (action === 'done') break;
|
|
60
|
+
if (action === 'cancel') throw new CancelError();
|
|
61
|
+
|
|
62
|
+
if (action === 'personal') {
|
|
63
|
+
await pickFromSource(
|
|
64
|
+
() => gh.listPersonalRepos(),
|
|
65
|
+
`Personal repos (@${user || 'you'})`,
|
|
66
|
+
selected,
|
|
67
|
+
);
|
|
68
|
+
} else if (action === 'orgs') {
|
|
69
|
+
const org = await pickOrg(orgs);
|
|
70
|
+
if (org) {
|
|
71
|
+
await pickFromSource(
|
|
72
|
+
() => gh.listOrgRepos(org),
|
|
73
|
+
`${org} repos`,
|
|
74
|
+
selected,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
} else if (action === 'search') {
|
|
78
|
+
await searchFlow(selected);
|
|
79
|
+
} else if (action === 'paste') {
|
|
80
|
+
await pasteUrlLoop(selected);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (selected.size === 0) {
|
|
85
|
+
throw new CancelError('No repos selected.');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
ui.blank();
|
|
89
|
+
const fullHistory = await confirm({
|
|
90
|
+
message: 'Import full history? (slower, larger clone)',
|
|
91
|
+
default: false,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const firstRepo = selected.values().next().value;
|
|
95
|
+
const defaultDir = suggestDir(firstRepo);
|
|
96
|
+
const dir = await input({
|
|
97
|
+
message: 'Workspace directory name',
|
|
98
|
+
default: defaultDir,
|
|
99
|
+
validate: (v) => (v && v.trim() ? true : 'Required'),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
ui.blank();
|
|
103
|
+
reviewSummary(dir.trim(), [...selected.values()], fullHistory);
|
|
104
|
+
const ok = await confirm({ message: 'Proceed?', default: true });
|
|
105
|
+
if (!ok) throw new CancelError();
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
dir: dir.trim(),
|
|
109
|
+
repos: [...selected.keys()],
|
|
110
|
+
fullHistory,
|
|
111
|
+
};
|
|
112
|
+
} catch (err) {
|
|
113
|
+
if (err && (err.name === 'ExitPromptError' || err instanceof CancelError)) {
|
|
114
|
+
throw new CancelError(err.message || 'Setup cancelled.');
|
|
115
|
+
}
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Source menu ─────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
function buildSourceMenu({ ghOk, user, orgs, count }) {
|
|
123
|
+
const choices = [];
|
|
124
|
+
if (ghOk) {
|
|
125
|
+
choices.push({
|
|
126
|
+
name: `Personal${user ? ` (@${user})` : ''}`,
|
|
127
|
+
value: 'personal',
|
|
128
|
+
});
|
|
129
|
+
if (orgs.length > 0) {
|
|
130
|
+
choices.push({
|
|
131
|
+
name: `Organizations (${orgs.length}) →`,
|
|
132
|
+
value: 'orgs',
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
choices.push({ name: 'Search GitHub…', value: 'search' });
|
|
136
|
+
}
|
|
137
|
+
choices.push({ name: 'Paste URL', value: 'paste' });
|
|
138
|
+
|
|
139
|
+
choices.push(new Separator());
|
|
140
|
+
if (count > 0) {
|
|
141
|
+
choices.push({ name: `${chalk.green('✔')} Done (${count} selected)`, value: 'done' });
|
|
142
|
+
}
|
|
143
|
+
choices.push({ name: `${chalk.red('✖')} Cancel`, value: 'cancel' });
|
|
144
|
+
return choices;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Custom toggle-list prompt ───────────────────────────────────────────────
|
|
148
|
+
//
|
|
149
|
+
// @inquirer/prompts' built-in checkbox hardcodes space=toggle / enter=submit
|
|
150
|
+
// and prints a summary line on every resolution, which is why a `select`-in-
|
|
151
|
+
// a-loop implementation left trails of `[x] repo` ghost lines behind. A
|
|
152
|
+
// single custom prompt built on @inquirer/core keeps everything on one
|
|
153
|
+
// persistent frame: enter toggles, esc returns, hint sits below the list.
|
|
154
|
+
|
|
155
|
+
const repoToggleList = createPrompt((config, done) => {
|
|
156
|
+
const { message, items: initialItems, pageSize = 15 } = config;
|
|
157
|
+
const theme = makeTheme(config.theme);
|
|
158
|
+
const prefix = usePrefix({ theme });
|
|
159
|
+
|
|
160
|
+
const [items, setItems] = useState(initialItems);
|
|
161
|
+
const [cursor, setCursor] = useState(0);
|
|
162
|
+
const [status, setStatus] = useState('idle');
|
|
163
|
+
|
|
164
|
+
useKeypress((key) => {
|
|
165
|
+
if (status === 'done') return;
|
|
166
|
+
if (isUpKey(key)) {
|
|
167
|
+
setCursor(cursor > 0 ? cursor - 1 : items.length - 1);
|
|
168
|
+
} else if (isDownKey(key)) {
|
|
169
|
+
setCursor(cursor < items.length - 1 ? cursor + 1 : 0);
|
|
170
|
+
} else if (isEnterKey(key)) {
|
|
171
|
+
const next = items.map((it, i) =>
|
|
172
|
+
i === cursor ? { ...it, checked: !it.checked } : it,
|
|
173
|
+
);
|
|
174
|
+
setItems(next);
|
|
175
|
+
} else if (key.name === 'escape') {
|
|
176
|
+
setStatus('done');
|
|
177
|
+
done(items.filter((it) => it.checked).map((it) => it.value));
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const page = usePagination({
|
|
182
|
+
items,
|
|
183
|
+
active: cursor,
|
|
184
|
+
renderItem: ({ item, isActive }) => {
|
|
185
|
+
const box = item.checked ? chalk.green('[x]') : chalk.dim('[ ]');
|
|
186
|
+
const priv = item.isPrivate ? chalk.dim(' [private]') : '';
|
|
187
|
+
const line = `${box} ${item.nameWithOwner}${priv}`;
|
|
188
|
+
return isActive ? chalk.cyan(`❯ ${line}`) : ` ${line}`;
|
|
189
|
+
},
|
|
190
|
+
pageSize,
|
|
191
|
+
loop: false,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const count = items.filter((it) => it.checked).length;
|
|
195
|
+
const countText = chalk.dim(`(${count} selected)`);
|
|
196
|
+
|
|
197
|
+
if (status === 'done') {
|
|
198
|
+
// Single residual line — no ghost spam.
|
|
199
|
+
return `${prefix} ${chalk.bold(message)} ${countText}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const header = `${prefix} ${chalk.bold(message)} ${countText}`;
|
|
203
|
+
const hint = chalk.dim(' ↑↓ navigate • ⏎ toggle • esc back');
|
|
204
|
+
return [`${header}\n${page}`, hint];
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// ── Pickers ─────────────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
async function pickFromSource(fetcher, label, selected) {
|
|
210
|
+
let repos;
|
|
211
|
+
try {
|
|
212
|
+
ui.info(`Fetching ${label}…`);
|
|
213
|
+
repos = fetcher();
|
|
214
|
+
} catch (err) {
|
|
215
|
+
ui.error(`Could not fetch ${label}: ${err.message}`);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (!repos || repos.length === 0) {
|
|
219
|
+
ui.warning(`No repos found in ${label}.`);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
await runToggleList(label, repos, selected);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function runToggleList(label, repos, selected) {
|
|
226
|
+
const items = repos.map((r) => ({
|
|
227
|
+
...r,
|
|
228
|
+
checked: selected.has(r.url),
|
|
229
|
+
value: r.url,
|
|
230
|
+
}));
|
|
231
|
+
|
|
232
|
+
const picked = await repoToggleList({
|
|
233
|
+
message: label,
|
|
234
|
+
items,
|
|
235
|
+
pageSize: 15,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const sourceUrls = new Set(repos.map((r) => r.url));
|
|
239
|
+
const pickedSet = new Set(picked);
|
|
240
|
+
for (const url of sourceUrls) {
|
|
241
|
+
if (pickedSet.has(url)) {
|
|
242
|
+
const repo = repos.find((r) => r.url === url);
|
|
243
|
+
selected.set(url, repo);
|
|
244
|
+
} else if (selected.has(url)) {
|
|
245
|
+
selected.delete(url);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function pickOrg(orgs) {
|
|
251
|
+
if (orgs.length === 0) {
|
|
252
|
+
ui.warning('You are not a member of any organizations.');
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
return select({
|
|
256
|
+
message: 'Choose an organization',
|
|
257
|
+
choices: orgs.map((o) => ({ name: o.login, value: o.login })),
|
|
258
|
+
pageSize: 12,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function searchFlow(selected) {
|
|
263
|
+
const query = await input({
|
|
264
|
+
message: 'Search GitHub repos',
|
|
265
|
+
validate: (v) => (v && v.trim().length >= 2 ? true : 'Enter at least 2 characters'),
|
|
266
|
+
});
|
|
267
|
+
let results;
|
|
268
|
+
try {
|
|
269
|
+
ui.info(`Searching for "${query}"…`);
|
|
270
|
+
results = gh.searchRepos(query.trim());
|
|
271
|
+
} catch (err) {
|
|
272
|
+
ui.error(`Search failed: ${err.message}`);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (!results || results.length === 0) {
|
|
276
|
+
ui.warning('No results.');
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
await runToggleList(`Results for "${query}"`, results, selected);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function pasteUrlLoop(selected) {
|
|
283
|
+
while (true) {
|
|
284
|
+
const url = await input({
|
|
285
|
+
message: 'Repo URL (https:// or git@)',
|
|
286
|
+
validate: (v) => {
|
|
287
|
+
if (!v || !v.trim()) return 'Required';
|
|
288
|
+
try {
|
|
289
|
+
validateUrls([v.trim()]);
|
|
290
|
+
return true;
|
|
291
|
+
} catch (err) {
|
|
292
|
+
return err.message;
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
const trimmed = url.trim();
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
ui.info('Checking reachability…');
|
|
300
|
+
validateReachable(trimmed);
|
|
301
|
+
} catch (err) {
|
|
302
|
+
ui.error(err.message.split('\n')[0]);
|
|
303
|
+
const retry = await confirm({ message: 'Try another URL?', default: true });
|
|
304
|
+
if (!retry) return;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const name = extractRepoName(trimmed);
|
|
309
|
+
selected.set(trimmed, { nameWithOwner: name, url: trimmed });
|
|
310
|
+
ui.success(`Added ${name}`);
|
|
311
|
+
|
|
312
|
+
const more = await confirm({ message: 'Add another URL?', default: false });
|
|
313
|
+
if (!more) return;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
function suggestDir(firstRepo) {
|
|
320
|
+
if (!firstRepo) return 'my-monorepo';
|
|
321
|
+
const base = firstRepo.nameWithOwner
|
|
322
|
+
? firstRepo.nameWithOwner.split('/').pop()
|
|
323
|
+
: extractRepoName(firstRepo.url);
|
|
324
|
+
return `${base}-mono`;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function safeListOrgs() {
|
|
328
|
+
try {
|
|
329
|
+
return gh.listOrgs();
|
|
330
|
+
} catch {
|
|
331
|
+
return [];
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function reviewSummary(dir, repos, fullHistory) {
|
|
336
|
+
console.log(chalk.bold(` Review (${repos.length} repo${repos.length === 1 ? '' : 's'} → ./${dir})`));
|
|
337
|
+
for (const r of repos) {
|
|
338
|
+
const mode = fullHistory ? chalk.dim('(full history)') : chalk.dim('(shallow)');
|
|
339
|
+
console.log(` ${chalk.cyan(r.nameWithOwner || extractRepoName(r.url))} ${mode}`);
|
|
340
|
+
}
|
|
341
|
+
console.log();
|
|
342
|
+
}
|
package/src/ui.js
CHANGED
|
@@ -29,6 +29,14 @@ export function header(text) {
|
|
|
29
29
|
console.log(chalk.dim(' ' + '─'.repeat(50)));
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
export function interactiveHeader() {
|
|
33
|
+
console.log();
|
|
34
|
+
console.log(chalk.bold.cyan(` ${ICON.git} Unirepo — Interactive setup`));
|
|
35
|
+
console.log(chalk.dim(' ' + '─'.repeat(50)));
|
|
36
|
+
console.log(chalk.dim(' Pick repos to bundle into a new subtree monorepo.'));
|
|
37
|
+
console.log();
|
|
38
|
+
}
|
|
39
|
+
|
|
32
40
|
export function step(n, total, text) {
|
|
33
41
|
const prefix = chalk.dim(` [${n}/${total}]`);
|
|
34
42
|
console.log(`${prefix} ${text}`);
|
|
@@ -136,6 +144,8 @@ export function initSummary(dir, count, subtreeNames) {
|
|
|
136
144
|
console.log(chalk.bold(' Next steps:'));
|
|
137
145
|
console.log(` ${chalk.dim('$')} cd ${dir.includes(' ') ? `"${dir}"` : dir}`);
|
|
138
146
|
console.log(` ${chalk.dim('$')} unirepo status`);
|
|
147
|
+
console.log(` ${chalk.dim('# create a branch to target when pushing upstream')}`);
|
|
148
|
+
console.log(` ${chalk.dim('$')} unirepo branch feature-x`);
|
|
139
149
|
console.log(` ${chalk.dim('# edit files in')} ${subtreeNames.map(n => chalk.cyan(n + '/')).join(', ')}`);
|
|
140
150
|
console.log(` ${chalk.dim('$')} git add . && git commit -m "feat: ..."`);
|
|
141
151
|
console.log(` ${chalk.dim('$')} unirepo push --dry-run`);
|