@goodtek/vibeops 0.2.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/CHANGELOG.md +28 -0
- package/LICENSE +21 -0
- package/README.md +444 -0
- package/dist/agent/loader.js +71 -0
- package/dist/agent/prompt.js +66 -0
- package/dist/bootstrap/installer.js +149 -0
- package/dist/bootstrap/manifest.js +15 -0
- package/dist/bootstrap/substitute.js +35 -0
- package/dist/cli.js +241 -0
- package/dist/commands/agent-list.js +32 -0
- package/dist/commands/agent-prompt.js +59 -0
- package/dist/commands/agent-show.js +26 -0
- package/dist/commands/github-init.js +554 -0
- package/dist/commands/github-status.js +164 -0
- package/dist/commands/init.js +179 -0
- package/dist/commands/notion-init.js +764 -0
- package/dist/commands/notion-sync.js +405 -0
- package/dist/commands/notion-test.js +595 -0
- package/dist/commands/plan.js +114 -0
- package/dist/commands/status.js +17 -0
- package/dist/commands/task-check.js +155 -0
- package/dist/commands/task-done.js +98 -0
- package/dist/commands/task-generate.js +206 -0
- package/dist/commands/task-pull.js +277 -0
- package/dist/commands/task-rollback.js +174 -0
- package/dist/commands/task-start.js +90 -0
- package/dist/lib/brief.js +349 -0
- package/dist/lib/config.js +158 -0
- package/dist/lib/filesystem.js +67 -0
- package/dist/lib/git.js +237 -0
- package/dist/lib/github-cli.js +247 -0
- package/dist/lib/inquirer-helpers.js +111 -0
- package/dist/lib/logger.js +42 -0
- package/dist/lib/notion-client.js +459 -0
- package/dist/lib/notion-discovery.js +671 -0
- package/dist/lib/notion-env.js +140 -0
- package/dist/lib/notion-mappers.js +148 -0
- package/dist/lib/notion-schema.js +272 -0
- package/dist/lib/notion-sync.js +337 -0
- package/dist/lib/notion-target.js +247 -0
- package/dist/lib/package-json.js +133 -0
- package/dist/lib/paths.js +26 -0
- package/dist/lib/project-docs.js +95 -0
- package/dist/lib/prompt-builder.js +125 -0
- package/dist/lib/task-generator.js +183 -0
- package/dist/lib/task-prompt.js +23 -0
- package/dist/lib/task-pull.js +354 -0
- package/dist/lib/task-scaffold.js +128 -0
- package/dist/lib/task-summary.js +276 -0
- package/dist/lib/task.js +364 -0
- package/dist/status/collector.js +103 -0
- package/dist/status/format.js +177 -0
- package/dist/types/brief.js +126 -0
- package/dist/types/config.js +17 -0
- package/dist/types/task.js +1 -0
- package/dist/version.js +8 -0
- package/package.json +61 -0
- package/templates/.cursor/rules/00-project-governance.mdc +28 -0
- package/templates/.cursor/rules/01-agent-orchestration.mdc +48 -0
- package/templates/.cursor/rules/02-task-workflow.mdc +38 -0
- package/templates/.cursor/rules/03-git-safety.mdc +30 -0
- package/templates/.cursor/rules/04-docs-update.mdc +22 -0
- package/templates/.vibeops/agents/architect.md +47 -0
- package/templates/.vibeops/agents/builder.md +38 -0
- package/templates/.vibeops/agents/docs.md +54 -0
- package/templates/.vibeops/agents/orchestrator.md +40 -0
- package/templates/.vibeops/agents/planner.md +60 -0
- package/templates/.vibeops/agents/recovery.md +49 -0
- package/templates/.vibeops/agents/reviewer.md +47 -0
- package/templates/.vibeops/agents/tester.md +43 -0
- package/templates/.vibeops/prompts/create-plan.md +33 -0
- package/templates/.vibeops/prompts/generate-tasks.md +41 -0
- package/templates/.vibeops/prompts/implement-task.md +39 -0
- package/templates/.vibeops/prompts/review-task.md +34 -0
- package/templates/.vibeops/prompts/rollback.md +32 -0
- package/templates/.vibeops/prompts/start-project.md +39 -0
- package/templates/.vibeops/workflows/notion-sync.md +53 -0
- package/templates/.vibeops/workflows/project-start.md +73 -0
- package/templates/.vibeops/workflows/rollback.md +45 -0
- package/templates/.vibeops/workflows/task-lifecycle.md +71 -0
- package/templates/AGENTS.md +98 -0
- package/templates/docs/logs/README.md +38 -0
- package/templates/docs/project/00-overview.md +27 -0
- package/templates/docs/project/01-requirements.md +30 -0
- package/templates/docs/project/02-mvp-scope.md +36 -0
- package/templates/docs/project/03-architecture.md +34 -0
- package/templates/docs/project/04-tech-stack.md +29 -0
- package/templates/docs/project/05-current-state.md +35 -0
- package/templates/docs/project/06-decisions.md +20 -0
- package/templates/docs/project/07-backlog.md +23 -0
- package/templates/docs/project/08-env.md +29 -0
- package/templates/docs/project/09-deployment.md +28 -0
- package/templates/docs/tasks/TASK-000-template.md +72 -0
package/dist/lib/git.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const exec = promisify(execFile);
|
|
4
|
+
async function tryGit(cwd, args) {
|
|
5
|
+
try {
|
|
6
|
+
const { stdout } = await exec("git", args, { cwd });
|
|
7
|
+
return { stdout };
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export async function runGit(cwd, args) {
|
|
14
|
+
const { stdout, stderr } = await exec("git", args, { cwd });
|
|
15
|
+
return { stdout, stderr };
|
|
16
|
+
}
|
|
17
|
+
export async function isGitRepository(cwd) {
|
|
18
|
+
return (await tryGit(cwd, ["rev-parse", "--is-inside-work-tree"])) !== null;
|
|
19
|
+
}
|
|
20
|
+
export async function hasAnyCommit(cwd) {
|
|
21
|
+
return (await tryGit(cwd, ["rev-parse", "--verify", "HEAD"])) !== null;
|
|
22
|
+
}
|
|
23
|
+
export async function currentBranchOrUnborn(cwd) {
|
|
24
|
+
const hasCommits = await hasAnyCommit(cwd);
|
|
25
|
+
const symbolic = await tryGit(cwd, ["symbolic-ref", "--short", "HEAD"]);
|
|
26
|
+
if (symbolic !== null) {
|
|
27
|
+
const branch = symbolic.stdout.trim();
|
|
28
|
+
return {
|
|
29
|
+
branch: branch.length > 0 ? branch : null,
|
|
30
|
+
state: hasCommits ? "normal" : "unborn",
|
|
31
|
+
hasCommits,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
const detached = await tryGit(cwd, ["rev-parse", "--short", "HEAD"]);
|
|
35
|
+
return {
|
|
36
|
+
branch: detached !== null ? detached.stdout.trim() : null,
|
|
37
|
+
state: "detached",
|
|
38
|
+
hasCommits,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
export async function readGitInfo(cwd) {
|
|
42
|
+
if (!(await isGitRepository(cwd))) {
|
|
43
|
+
return { isRepo: false, branch: null, state: "none", hasCommits: null, dirty: null };
|
|
44
|
+
}
|
|
45
|
+
const branch = await currentBranchOrUnborn(cwd);
|
|
46
|
+
const statusRes = await tryGit(cwd, ["status", "--porcelain"]);
|
|
47
|
+
const dirty = statusRes ? statusRes.stdout.trim().length > 0 : null;
|
|
48
|
+
return {
|
|
49
|
+
isRepo: true,
|
|
50
|
+
branch: branch.branch,
|
|
51
|
+
state: branch.state,
|
|
52
|
+
hasCommits: branch.hasCommits,
|
|
53
|
+
dirty,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export async function gitInit(cwd) {
|
|
57
|
+
await runGit(cwd, ["init"]);
|
|
58
|
+
}
|
|
59
|
+
export async function gitSetDefaultBranch(cwd, branch) {
|
|
60
|
+
if (await hasAnyCommit(cwd)) {
|
|
61
|
+
await runGit(cwd, ["branch", "-M", branch]);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
await runGit(cwd, ["symbolic-ref", "HEAD", `refs/heads/${branch}`]);
|
|
65
|
+
}
|
|
66
|
+
export async function gitAddAll(cwd) {
|
|
67
|
+
await runGit(cwd, ["add", "."]);
|
|
68
|
+
}
|
|
69
|
+
export async function gitCommit(cwd, message) {
|
|
70
|
+
await runGit(cwd, ["commit", "-m", message]);
|
|
71
|
+
}
|
|
72
|
+
export async function gitHeadCommit(cwd, short = true) {
|
|
73
|
+
const res = await tryGit(cwd, short ? ["rev-parse", "--short", "HEAD"] : ["rev-parse", "HEAD"]);
|
|
74
|
+
return res ? res.stdout.trim() : null;
|
|
75
|
+
}
|
|
76
|
+
export async function gitBranchExists(cwd, name) {
|
|
77
|
+
const res = await tryGit(cwd, ["show-ref", "--verify", "--quiet", `refs/heads/${name}`]);
|
|
78
|
+
return res !== null;
|
|
79
|
+
}
|
|
80
|
+
export async function gitCreateBranch(cwd, name, startPoint) {
|
|
81
|
+
const args = ["branch", name];
|
|
82
|
+
if (typeof startPoint === "string" && startPoint.length > 0)
|
|
83
|
+
args.push(startPoint);
|
|
84
|
+
await runGit(cwd, args);
|
|
85
|
+
}
|
|
86
|
+
export async function gitCheckout(cwd, ref) {
|
|
87
|
+
await runGit(cwd, ["checkout", ref]);
|
|
88
|
+
}
|
|
89
|
+
export async function gitCheckoutNewBranch(cwd, name, startPoint) {
|
|
90
|
+
const args = ["checkout", "-b", name];
|
|
91
|
+
if (typeof startPoint === "string" && startPoint.length > 0)
|
|
92
|
+
args.push(startPoint);
|
|
93
|
+
await runGit(cwd, args);
|
|
94
|
+
}
|
|
95
|
+
export async function gitDeleteBranch(cwd, name, opts = {}) {
|
|
96
|
+
const flag = opts.force === true ? "-D" : "-d";
|
|
97
|
+
await runGit(cwd, ["branch", flag, name]);
|
|
98
|
+
}
|
|
99
|
+
export async function gitResetHard(cwd, ref) {
|
|
100
|
+
await runGit(cwd, ["reset", "--hard", ref]);
|
|
101
|
+
}
|
|
102
|
+
export async function gitDiffNameOnly(cwd, range) {
|
|
103
|
+
const args = ["diff", "--name-only"];
|
|
104
|
+
if (typeof range === "string" && range.length > 0)
|
|
105
|
+
args.push(range);
|
|
106
|
+
const res = await tryGit(cwd, args);
|
|
107
|
+
if (!res)
|
|
108
|
+
return [];
|
|
109
|
+
return res.stdout
|
|
110
|
+
.split("\n")
|
|
111
|
+
.map((s) => s.trim())
|
|
112
|
+
.filter((s) => s.length > 0);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Raw lines from `git status --porcelain` (read-only).
|
|
116
|
+
* Returns an empty array if the repo is unreadable.
|
|
117
|
+
*/
|
|
118
|
+
export async function gitStatusPorcelain(cwd) {
|
|
119
|
+
const res = await tryGit(cwd, ["status", "--porcelain"]);
|
|
120
|
+
if (!res)
|
|
121
|
+
return [];
|
|
122
|
+
return res.stdout.split("\n").filter((line) => line.length > 0);
|
|
123
|
+
}
|
|
124
|
+
function parsePorcelainLine(line) {
|
|
125
|
+
if (line.length < 3)
|
|
126
|
+
return null;
|
|
127
|
+
const code = line.slice(0, 2);
|
|
128
|
+
const rest = line.slice(3);
|
|
129
|
+
if (code === "??") {
|
|
130
|
+
return { code, staged: false, unstaged: false, untracked: true, path: rest };
|
|
131
|
+
}
|
|
132
|
+
if (code === "!!") {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
const x = code.charAt(0);
|
|
136
|
+
const y = code.charAt(1);
|
|
137
|
+
// Rename / copy: `R old -> new` or `C old -> new`
|
|
138
|
+
if (x === "R" || x === "C") {
|
|
139
|
+
const arrow = rest.indexOf(" -> ");
|
|
140
|
+
if (arrow >= 0) {
|
|
141
|
+
const origPath = rest.slice(0, arrow);
|
|
142
|
+
const path = rest.slice(arrow + 4);
|
|
143
|
+
return {
|
|
144
|
+
code,
|
|
145
|
+
staged: true,
|
|
146
|
+
unstaged: y !== " " && y !== "?",
|
|
147
|
+
untracked: false,
|
|
148
|
+
path,
|
|
149
|
+
origPath,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
code,
|
|
155
|
+
staged: x !== " " && x !== "?",
|
|
156
|
+
unstaged: y !== " " && y !== "?",
|
|
157
|
+
untracked: false,
|
|
158
|
+
path: rest,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
function parsePorcelain(lines) {
|
|
162
|
+
const out = [];
|
|
163
|
+
for (const line of lines) {
|
|
164
|
+
const entry = parsePorcelainLine(line);
|
|
165
|
+
if (entry !== null)
|
|
166
|
+
out.push(entry);
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
export async function gitWorkingTreeChangedFiles(cwd) {
|
|
171
|
+
const entries = parsePorcelain(await gitStatusPorcelain(cwd));
|
|
172
|
+
return entries.filter((e) => e.unstaged).map((e) => e.path);
|
|
173
|
+
}
|
|
174
|
+
export async function gitStagedChangedFiles(cwd) {
|
|
175
|
+
const entries = parsePorcelain(await gitStatusPorcelain(cwd));
|
|
176
|
+
return entries.filter((e) => e.staged).map((e) => e.path);
|
|
177
|
+
}
|
|
178
|
+
export async function gitUntrackedFiles(cwd) {
|
|
179
|
+
const entries = parsePorcelain(await gitStatusPorcelain(cwd));
|
|
180
|
+
return entries.filter((e) => e.untracked).map((e) => e.path);
|
|
181
|
+
}
|
|
182
|
+
export async function gitCommittedChangedFilesSince(baseCommit, cwd) {
|
|
183
|
+
if (baseCommit.length === 0)
|
|
184
|
+
return [];
|
|
185
|
+
return gitDiffNameOnly(cwd, `${baseCommit}..HEAD`);
|
|
186
|
+
}
|
|
187
|
+
export async function gitAllChangedFilesSinceTaskStart(baseCommit, cwd) {
|
|
188
|
+
const entries = parsePorcelain(await gitStatusPorcelain(cwd));
|
|
189
|
+
const working = entries.filter((e) => e.unstaged).map((e) => e.path);
|
|
190
|
+
const staged = entries.filter((e) => e.staged).map((e) => e.path);
|
|
191
|
+
const untracked = entries.filter((e) => e.untracked).map((e) => e.path);
|
|
192
|
+
const committed = await gitCommittedChangedFilesSince(baseCommit, cwd);
|
|
193
|
+
const workingTree = Array.from(new Set([...working, ...staged, ...untracked]));
|
|
194
|
+
const all = Array.from(new Set([...workingTree, ...committed]));
|
|
195
|
+
return { working, staged, untracked, committed, workingTree, all };
|
|
196
|
+
}
|
|
197
|
+
export async function gitLogOneline(cwd, range) {
|
|
198
|
+
const args = ["log", "--oneline", "--no-decorate"];
|
|
199
|
+
if (typeof range === "string" && range.length > 0)
|
|
200
|
+
args.push(range);
|
|
201
|
+
const res = await tryGit(cwd, args);
|
|
202
|
+
if (!res)
|
|
203
|
+
return [];
|
|
204
|
+
const out = [];
|
|
205
|
+
for (const line of res.stdout.split("\n")) {
|
|
206
|
+
const m = /^([0-9a-f]+)\s+(.+)$/i.exec(line.trim());
|
|
207
|
+
if (m)
|
|
208
|
+
out.push({ sha: m[1], message: m[2] });
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
export async function gitCommitsAhead(cwd, baseRef, headRef = "HEAD") {
|
|
213
|
+
const res = await tryGit(cwd, ["rev-list", "--count", `${baseRef}..${headRef}`]);
|
|
214
|
+
if (!res)
|
|
215
|
+
return 0;
|
|
216
|
+
const n = Number.parseInt(res.stdout.trim(), 10);
|
|
217
|
+
return Number.isFinite(n) ? n : 0;
|
|
218
|
+
}
|
|
219
|
+
export async function detectDefaultBranch(cwd) {
|
|
220
|
+
for (const cand of ["main", "master"]) {
|
|
221
|
+
if (await gitBranchExists(cwd, cand))
|
|
222
|
+
return cand;
|
|
223
|
+
}
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Read the `origin` remote URL (e.g. `git@github.com:org/repo.git`).
|
|
228
|
+
* Returns `null` if the repo has no `origin` remote or git isn't available.
|
|
229
|
+
* Read-only — never adds, sets, or fetches.
|
|
230
|
+
*/
|
|
231
|
+
export async function gitRemoteUrl(cwd, name = "origin") {
|
|
232
|
+
const res = await tryGit(cwd, ["remote", "get-url", name]);
|
|
233
|
+
if (!res)
|
|
234
|
+
return null;
|
|
235
|
+
const url = res.stdout.trim();
|
|
236
|
+
return url.length > 0 ? url : null;
|
|
237
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { runGit } from "./git.js";
|
|
4
|
+
const exec = promisify(execFile);
|
|
5
|
+
export async function runGh(args) {
|
|
6
|
+
try {
|
|
7
|
+
const { stdout, stderr } = await exec("gh", [...args], { env: process.env });
|
|
8
|
+
return { ok: true, stdout, stderr, exitCode: 0 };
|
|
9
|
+
}
|
|
10
|
+
catch (err) {
|
|
11
|
+
const e = err;
|
|
12
|
+
return {
|
|
13
|
+
ok: false,
|
|
14
|
+
stdout: e.stdout ?? "",
|
|
15
|
+
stderr: e.stderr ?? (typeof e.message === "string" ? e.message : ""),
|
|
16
|
+
exitCode: typeof e.code === "number" ? e.code : null,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Returns true if `gh --version` exits 0. */
|
|
21
|
+
export async function isGhInstalled() {
|
|
22
|
+
const res = await runGh(["--version"]);
|
|
23
|
+
return res.ok;
|
|
24
|
+
}
|
|
25
|
+
const USERNAME_RE = /Logged in to (?:github\.com|[\w.-]+) (?:as|account) ([^\s)]+)/i;
|
|
26
|
+
export async function ghAuthStatus() {
|
|
27
|
+
const installed = await isGhInstalled();
|
|
28
|
+
if (!installed) {
|
|
29
|
+
return {
|
|
30
|
+
installed: false,
|
|
31
|
+
authenticated: false,
|
|
32
|
+
username: null,
|
|
33
|
+
hosts: [],
|
|
34
|
+
detail: "gh CLI not installed",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const res = await runGh(["auth", "status"]);
|
|
38
|
+
const text = `${res.stdout}\n${res.stderr}`;
|
|
39
|
+
const authenticated = res.ok || /Logged in to/i.test(text);
|
|
40
|
+
const userMatch = USERNAME_RE.exec(text);
|
|
41
|
+
const hosts = [];
|
|
42
|
+
for (const m of text.matchAll(/^([\w.-]+\.com)\s*$/gm)) {
|
|
43
|
+
const host = m[1]?.trim();
|
|
44
|
+
if (typeof host === "string" && host.length > 0 && !hosts.includes(host)) {
|
|
45
|
+
hosts.push(host);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (hosts.length === 0 && /github\.com/i.test(text))
|
|
49
|
+
hosts.push("github.com");
|
|
50
|
+
return {
|
|
51
|
+
installed: true,
|
|
52
|
+
authenticated,
|
|
53
|
+
username: userMatch !== null ? userMatch[1].trim() : null,
|
|
54
|
+
hosts,
|
|
55
|
+
// Strip any potential token-looking strings defensively.
|
|
56
|
+
detail: maskAuthDetail(text),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function maskAuthDetail(text) {
|
|
60
|
+
return text
|
|
61
|
+
.replace(/gh[op]_[A-Za-z0-9]{20,}/g, "gh***")
|
|
62
|
+
.replace(/ghp_[A-Za-z0-9]{20,}/g, "gh***")
|
|
63
|
+
.replace(/ghu_[A-Za-z0-9]{20,}/g, "gh***")
|
|
64
|
+
.replace(/(Authorization:\s*Bearer\s+)\S+/gi, "$1***")
|
|
65
|
+
.trim();
|
|
66
|
+
}
|
|
67
|
+
/** Best-effort `gh api user --jq .login`. Returns `null` when gh fails. */
|
|
68
|
+
export async function ghCurrentUser() {
|
|
69
|
+
const res = await runGh(["api", "user", "--jq", ".login"]);
|
|
70
|
+
if (!res.ok)
|
|
71
|
+
return null;
|
|
72
|
+
const login = res.stdout.trim();
|
|
73
|
+
return login.length > 0 ? login : null;
|
|
74
|
+
}
|
|
75
|
+
export async function ghRepoExists(owner, repo) {
|
|
76
|
+
if (owner.length === 0 || repo.length === 0)
|
|
77
|
+
return false;
|
|
78
|
+
const res = await runGh(["repo", "view", `${owner}/${repo}`, "--json", "name"]);
|
|
79
|
+
return res.ok;
|
|
80
|
+
}
|
|
81
|
+
export function buildGhCreateRepoArgs(input) {
|
|
82
|
+
const slug = `${input.owner}/${input.repo}`;
|
|
83
|
+
const args = ["repo", "create", slug];
|
|
84
|
+
args.push(input.visibility === "public" ? "--public" : "--private");
|
|
85
|
+
if (typeof input.source === "string" && input.source.length > 0) {
|
|
86
|
+
args.push(`--source=${input.source}`);
|
|
87
|
+
}
|
|
88
|
+
args.push(`--remote=${input.remote ?? "origin"}`);
|
|
89
|
+
if (typeof input.description === "string" && input.description.length > 0) {
|
|
90
|
+
args.push("--description", input.description);
|
|
91
|
+
}
|
|
92
|
+
return args;
|
|
93
|
+
}
|
|
94
|
+
export async function ghCreateRepo(input) {
|
|
95
|
+
const argv = buildGhCreateRepoArgs(input);
|
|
96
|
+
const command = `gh ${argv.map(shellSafe).join(" ")}`;
|
|
97
|
+
if (input.dryRun === true) {
|
|
98
|
+
return { ok: true, command, argv, dryRun: true };
|
|
99
|
+
}
|
|
100
|
+
const res = await runGh(argv);
|
|
101
|
+
return {
|
|
102
|
+
ok: res.ok,
|
|
103
|
+
command,
|
|
104
|
+
argv,
|
|
105
|
+
stdout: res.stdout,
|
|
106
|
+
stderr: res.stderr,
|
|
107
|
+
exitCode: res.exitCode,
|
|
108
|
+
dryRun: false,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Run `gh auth login` with the user's TTY (stdio inherit). Returns a promise
|
|
113
|
+
* resolving with the child's exit code. Caller is responsible for verifying
|
|
114
|
+
* `--dry-run` / `--yes` / non-TTY guards before invoking — this helper does
|
|
115
|
+
* NOT decide policy.
|
|
116
|
+
*/
|
|
117
|
+
export function ghAuthLoginInteractive() {
|
|
118
|
+
return new Promise((resolve) => {
|
|
119
|
+
const child = spawn("gh", ["auth", "login"], {
|
|
120
|
+
stdio: "inherit",
|
|
121
|
+
env: process.env,
|
|
122
|
+
});
|
|
123
|
+
child.on("exit", (code) => {
|
|
124
|
+
resolve(typeof code === "number" ? code : 1);
|
|
125
|
+
});
|
|
126
|
+
child.on("error", () => {
|
|
127
|
+
resolve(127);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
const RE_SSH = /^git@github\.com:([^/\s]+)\/([^/\s]+?)(?:\.git)?$/i;
|
|
132
|
+
const RE_HTTPS = /^https?:\/\/github\.com\/([^/\s?#]+)\/([^/\s?#]+?)(?:\.git)?(?:[?#].*)?$/i;
|
|
133
|
+
const RE_GIT_HTTPS = /^git\+https?:\/\/github\.com\/([^/\s?#]+)\/([^/\s?#]+?)(?:\.git)?(?:[?#].*)?$/i;
|
|
134
|
+
const RE_SLUG = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/;
|
|
135
|
+
/** Strip a trailing `.git` and any `?...#...` suffix from a repo name. */
|
|
136
|
+
function cleanRepoName(name) {
|
|
137
|
+
return name.replace(/\.git$/i, "").trim();
|
|
138
|
+
}
|
|
139
|
+
export function parseGitHubRemote(rawUrl) {
|
|
140
|
+
const url = rawUrl.trim();
|
|
141
|
+
const empty = {
|
|
142
|
+
isGithub: false,
|
|
143
|
+
owner: null,
|
|
144
|
+
repo: null,
|
|
145
|
+
url,
|
|
146
|
+
protocol: null,
|
|
147
|
+
httpsUrl: null,
|
|
148
|
+
gitHttpsUrl: null,
|
|
149
|
+
};
|
|
150
|
+
if (url.length === 0)
|
|
151
|
+
return empty;
|
|
152
|
+
let owner = null;
|
|
153
|
+
let repo = null;
|
|
154
|
+
let protocol = null;
|
|
155
|
+
const ssh = RE_SSH.exec(url);
|
|
156
|
+
if (ssh !== null) {
|
|
157
|
+
owner = ssh[1];
|
|
158
|
+
repo = cleanRepoName(ssh[2]);
|
|
159
|
+
protocol = "ssh";
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
const gitHttps = RE_GIT_HTTPS.exec(url);
|
|
163
|
+
if (gitHttps !== null) {
|
|
164
|
+
owner = gitHttps[1];
|
|
165
|
+
repo = cleanRepoName(gitHttps[2]);
|
|
166
|
+
protocol = "git";
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
const https = RE_HTTPS.exec(url);
|
|
170
|
+
if (https !== null) {
|
|
171
|
+
owner = https[1];
|
|
172
|
+
repo = cleanRepoName(https[2]);
|
|
173
|
+
protocol = "https";
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
const slug = RE_SLUG.exec(url);
|
|
177
|
+
if (slug !== null) {
|
|
178
|
+
owner = slug[1];
|
|
179
|
+
repo = cleanRepoName(slug[2]);
|
|
180
|
+
protocol = "slug";
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (owner === null || repo === null || repo.length === 0)
|
|
186
|
+
return empty;
|
|
187
|
+
const httpsUrl = `https://github.com/${owner}/${repo}`;
|
|
188
|
+
const gitHttpsUrl = `git+https://github.com/${owner}/${repo}.git`;
|
|
189
|
+
return {
|
|
190
|
+
isGithub: true,
|
|
191
|
+
owner,
|
|
192
|
+
repo,
|
|
193
|
+
url,
|
|
194
|
+
protocol,
|
|
195
|
+
httpsUrl,
|
|
196
|
+
gitHttpsUrl,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
export async function gitRemoteList(cwd) {
|
|
200
|
+
try {
|
|
201
|
+
const { stdout } = await exec("git", ["remote", "-v"], { cwd });
|
|
202
|
+
const out = [];
|
|
203
|
+
const seen = new Set();
|
|
204
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
205
|
+
const trimmed = line.trim();
|
|
206
|
+
if (trimmed.length === 0)
|
|
207
|
+
continue;
|
|
208
|
+
const m = /^([^\s]+)\s+([^\s]+)\s+\((fetch|push)\)$/.exec(trimmed);
|
|
209
|
+
if (m === null)
|
|
210
|
+
continue;
|
|
211
|
+
const name = m[1];
|
|
212
|
+
const url = m[2];
|
|
213
|
+
const key = `${name}\t${url}`;
|
|
214
|
+
if (seen.has(key))
|
|
215
|
+
continue;
|
|
216
|
+
seen.add(key);
|
|
217
|
+
out.push({ name, url });
|
|
218
|
+
}
|
|
219
|
+
// Deduplicate to one entry per remote name (prefer fetch).
|
|
220
|
+
const byName = new Map();
|
|
221
|
+
for (const e of out) {
|
|
222
|
+
if (!byName.has(e.name))
|
|
223
|
+
byName.set(e.name, e);
|
|
224
|
+
}
|
|
225
|
+
return Array.from(byName.values());
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return [];
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/** Add a new git remote. Throws if remote already exists or git fails. */
|
|
232
|
+
export async function gitRemoteAdd(cwd, name, url) {
|
|
233
|
+
await runGit(cwd, ["remote", "add", name, url]);
|
|
234
|
+
}
|
|
235
|
+
/** Update an existing git remote URL. Throws if remote does not exist. */
|
|
236
|
+
export async function gitRemoteSetUrl(cwd, name, url) {
|
|
237
|
+
await runGit(cwd, ["remote", "set-url", name, url]);
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Quote a single argv element for human-readable command preview. Not used
|
|
241
|
+
* to actually execute commands — we always go through execFile arg arrays.
|
|
242
|
+
*/
|
|
243
|
+
function shellSafe(s) {
|
|
244
|
+
if (/^[\w@/:.+=-]+$/.test(s))
|
|
245
|
+
return s;
|
|
246
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
247
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { checkbox, confirm, input, select } from "@inquirer/prompts";
|
|
2
|
+
export const OTHER_LABEL = "Other";
|
|
3
|
+
function isOther(value) {
|
|
4
|
+
return value === OTHER_LABEL;
|
|
5
|
+
}
|
|
6
|
+
function formatCustom(text) {
|
|
7
|
+
return `Other: ${text.trim()}`;
|
|
8
|
+
}
|
|
9
|
+
export async function askInput(opts) {
|
|
10
|
+
if (opts.nonInteractive) {
|
|
11
|
+
const value = opts.default ?? opts.fallback ?? "";
|
|
12
|
+
return value.trim();
|
|
13
|
+
}
|
|
14
|
+
const answer = await input({
|
|
15
|
+
message: opts.message,
|
|
16
|
+
default: opts.default,
|
|
17
|
+
validate: (raw) => {
|
|
18
|
+
if (opts.required && raw.trim().length === 0) {
|
|
19
|
+
return "This field is required.";
|
|
20
|
+
}
|
|
21
|
+
return true;
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
return answer.trim();
|
|
25
|
+
}
|
|
26
|
+
export async function askSelect(opts) {
|
|
27
|
+
if (opts.nonInteractive) {
|
|
28
|
+
const fallback = opts.default ?? opts.choices[0] ?? "";
|
|
29
|
+
return fallback;
|
|
30
|
+
}
|
|
31
|
+
const choices = opts.choices.map((c) => ({ name: c, value: c }));
|
|
32
|
+
const answer = await select({
|
|
33
|
+
message: opts.message,
|
|
34
|
+
choices,
|
|
35
|
+
default: opts.default ?? opts.choices[0],
|
|
36
|
+
loop: false,
|
|
37
|
+
pageSize: 8,
|
|
38
|
+
});
|
|
39
|
+
if (!isOther(answer))
|
|
40
|
+
return answer;
|
|
41
|
+
const custom = await input({
|
|
42
|
+
message: `↳ ${opts.message} — enter the value for "Other" (leave empty to keep "Other")`,
|
|
43
|
+
});
|
|
44
|
+
return custom.trim().length > 0 ? formatCustom(custom) : OTHER_LABEL;
|
|
45
|
+
}
|
|
46
|
+
export async function askCheckbox(opts) {
|
|
47
|
+
if (opts.nonInteractive) {
|
|
48
|
+
return [...(opts.default ?? [])];
|
|
49
|
+
}
|
|
50
|
+
const defaults = new Set(opts.default ?? []);
|
|
51
|
+
const choices = opts.choices.map((c) => ({ name: c, value: c, checked: defaults.has(c) }));
|
|
52
|
+
const answer = await checkbox({
|
|
53
|
+
message: `${opts.message} ${"\u001b[2m"}(arrow keys · Space · Enter)${"\u001b[0m"}`,
|
|
54
|
+
choices,
|
|
55
|
+
loop: false,
|
|
56
|
+
pageSize: 8,
|
|
57
|
+
});
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const item of answer) {
|
|
60
|
+
if (!isOther(item)) {
|
|
61
|
+
out.push(item);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const custom = await input({
|
|
65
|
+
message: `↳ ${opts.message} — enter values for "Other" (comma-separated, leave empty to keep "Other")`,
|
|
66
|
+
});
|
|
67
|
+
const trimmed = custom.trim();
|
|
68
|
+
if (trimmed.length === 0) {
|
|
69
|
+
out.push(OTHER_LABEL);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
for (const piece of trimmed.split(",")) {
|
|
73
|
+
const t = piece.trim();
|
|
74
|
+
if (t.length > 0)
|
|
75
|
+
out.push(formatCustom(t));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
export async function askConfirm(opts) {
|
|
81
|
+
if (opts.nonInteractive)
|
|
82
|
+
return opts.default;
|
|
83
|
+
return await confirm({ message: opts.message, default: opts.default });
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Yes/No question rendered as a 2-choice `select` prompt instead of a
|
|
87
|
+
* `confirm` prompt. This means the user navigates with ←/→ or ↑/↓ and
|
|
88
|
+
* presses Enter — they never have to type `y` or `n`.
|
|
89
|
+
*
|
|
90
|
+
* Returns a boolean (`true` = Yes, `false` = No).
|
|
91
|
+
*/
|
|
92
|
+
export async function yesNoSelect(opts) {
|
|
93
|
+
return await select({
|
|
94
|
+
message: opts.message,
|
|
95
|
+
choices: [
|
|
96
|
+
{ name: "Yes", value: true },
|
|
97
|
+
{ name: "No", value: false },
|
|
98
|
+
],
|
|
99
|
+
default: opts.defaultValue ?? true,
|
|
100
|
+
loop: false,
|
|
101
|
+
pageSize: 2,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Wrapper around `yesNoSelect` that respects a non-interactive flag (CI).
|
|
106
|
+
*/
|
|
107
|
+
export async function askYesNo(opts) {
|
|
108
|
+
if (opts.nonInteractive)
|
|
109
|
+
return opts.defaultValue ?? true;
|
|
110
|
+
return yesNoSelect({ message: opts.message, defaultValue: opts.defaultValue ?? true });
|
|
111
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/* eslint-disable no-console */
|
|
2
|
+
const useColor = () => {
|
|
3
|
+
if (process.env["NO_COLOR"])
|
|
4
|
+
return false;
|
|
5
|
+
if (process.env["FORCE_COLOR"])
|
|
6
|
+
return true;
|
|
7
|
+
return process.stdout.isTTY === true;
|
|
8
|
+
};
|
|
9
|
+
const color = (code, s) => (useColor() ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
10
|
+
export const dim = (s) => color("2", s);
|
|
11
|
+
export const bold = (s) => color("1", s);
|
|
12
|
+
export const green = (s) => color("32", s);
|
|
13
|
+
export const yellow = (s) => color("33", s);
|
|
14
|
+
export const red = (s) => color("31", s);
|
|
15
|
+
export const cyan = (s) => color("36", s);
|
|
16
|
+
export const gray = (s) => color("90", s);
|
|
17
|
+
export const log = {
|
|
18
|
+
info(msg) {
|
|
19
|
+
console.log(msg);
|
|
20
|
+
},
|
|
21
|
+
step(msg) {
|
|
22
|
+
console.log(`${cyan("→")} ${msg}`);
|
|
23
|
+
},
|
|
24
|
+
ok(msg) {
|
|
25
|
+
console.log(`${green("✓")} ${msg}`);
|
|
26
|
+
},
|
|
27
|
+
skip(msg) {
|
|
28
|
+
console.log(`${dim("·")} ${dim(msg)}`);
|
|
29
|
+
},
|
|
30
|
+
warn(msg) {
|
|
31
|
+
console.warn(`${yellow("!")} ${msg}`);
|
|
32
|
+
},
|
|
33
|
+
error(msg) {
|
|
34
|
+
console.error(`${red("✗")} ${msg}`);
|
|
35
|
+
},
|
|
36
|
+
raw(msg) {
|
|
37
|
+
process.stdout.write(msg);
|
|
38
|
+
},
|
|
39
|
+
blank() {
|
|
40
|
+
console.log("");
|
|
41
|
+
},
|
|
42
|
+
};
|