@goodtek/vibeops 1.0.0 → 1.1.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/CHANGELOG.md +31 -0
- package/README.md +51 -60
- package/dist/bootstrap/installer.js +38 -4
- package/dist/bootstrap/manifest.js +23 -3
- package/dist/cli.js +64 -15
- package/dist/commands/init.js +279 -114
- package/dist/commands/llm.js +177 -0
- package/dist/commands/status.js +37 -1
- package/dist/commands/task-add.js +33 -6
- package/dist/commands/task-done.js +27 -31
- package/dist/lib/config.js +84 -4
- package/dist/lib/env.js +32 -0
- package/dist/lib/git-config.js +21 -0
- package/dist/lib/git-host.js +12 -0
- package/dist/lib/git-policy.js +67 -0
- package/dist/lib/git-remote.js +108 -0
- package/dist/lib/git.js +43 -0
- package/dist/lib/init-clients.js +55 -0
- package/dist/lib/llm-complete.js +18 -19
- package/dist/lib/llm-connect.js +150 -0
- package/dist/lib/llm-preference.js +102 -0
- package/dist/lib/llm-status.js +19 -0
- package/dist/lib/pr-create.js +66 -0
- package/dist/lib/task-add-llm.js +8 -1
- package/dist/lib/task-done-llm.js +8 -1
- package/dist/lib/task-pr-llm.js +50 -0
- package/dist/lib/task-push-pr.js +131 -0
- package/dist/lib/task-start.js +46 -18
- package/dist/lib/task.js +15 -0
- package/package.json +13 -11
- package/templates/.cursor/rules/02-git-safety.mdc +5 -4
- package/templates/AGENTS.md +2 -1
- package/templates/clients/claude/CLAUDE.md +28 -0
- package/templates/clients/cursor/.cursor/rules/02-git-safety.mdc +11 -0
- package/templates/core/AGENTS.md +44 -0
- package/templates/{.cursor/skills → skills-shared}/implement-task/SKILL.md +1 -1
- package/templates/{.cursor/skills → skills-shared}/plan-task/SKILL.md +3 -3
- package/dist/lib/task-merge.js +0 -115
- /package/templates/{.cursor → clients/cursor/.cursor}/rules/01-task-source-of-truth.mdc +0 -0
- /package/templates/{.cursor → clients/cursor/.cursor}/rules/03-docs-on-done.mdc +0 -0
- /package/templates/{docs → core/docs}/logs/README.md +0 -0
- /package/templates/{docs → core/docs}/project/03-architecture.md +0 -0
- /package/templates/{docs → core/docs}/project/05-current-state.md +0 -0
- /package/templates/{docs → core/docs}/project/06-decisions.md +0 -0
- /package/templates/{docs → core/docs}/tasks/TASK-000-template.md +0 -0
package/dist/commands/status.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
|
-
import { readConfig } from "../lib/config.js";
|
|
2
|
+
import { readConfig, getClientsFromConfig } from "../lib/config.js";
|
|
3
|
+
import { formatGitPolicySummary } from "../lib/git-policy.js";
|
|
3
4
|
import { readText } from "../lib/filesystem.js";
|
|
4
5
|
import { readGitInfo } from "../lib/git.js";
|
|
6
|
+
import { buildLlmStatusReport } from "../lib/llm-status.js";
|
|
5
7
|
import { bold, cyan, dim, green, log, yellow } from "../lib/logger.js";
|
|
6
8
|
import { projectPaths } from "../lib/paths.js";
|
|
7
9
|
import { VERSION } from "../version.js";
|
|
@@ -29,6 +31,7 @@ async function buildReport(cwd) {
|
|
|
29
31
|
let taskBranch = null;
|
|
30
32
|
let branchExists = null;
|
|
31
33
|
let goalExcerpt = "";
|
|
34
|
+
let mergeRequestUrl = null;
|
|
32
35
|
if (focus !== null) {
|
|
33
36
|
const body = await readText(focus.filePath);
|
|
34
37
|
goalExcerpt = summarizeGoal(body);
|
|
@@ -36,6 +39,7 @@ async function buildReport(cwd) {
|
|
|
36
39
|
testFilled = hasNonEmptySection(body, "Test Result");
|
|
37
40
|
const ctx = await readGitContext(focus.filePath);
|
|
38
41
|
taskBranch = ctx?.taskBranch ?? null;
|
|
42
|
+
mergeRequestUrl = ctx?.mergeRequestUrl ?? null;
|
|
39
43
|
onTaskBranch = isOnTaskBranch(git.branch, ctx);
|
|
40
44
|
branchExists = await taskBranchExistsFor(cwd, focus);
|
|
41
45
|
focusBlock = {
|
|
@@ -49,6 +53,7 @@ async function buildReport(cwd) {
|
|
|
49
53
|
onTaskBranch,
|
|
50
54
|
taskBranch,
|
|
51
55
|
taskBranchExists: branchExists,
|
|
56
|
+
mergeRequestUrl,
|
|
52
57
|
};
|
|
53
58
|
}
|
|
54
59
|
const hint = computeNextHint({
|
|
@@ -58,14 +63,17 @@ async function buildReport(cwd) {
|
|
|
58
63
|
testFilled,
|
|
59
64
|
onTaskBranch,
|
|
60
65
|
});
|
|
66
|
+
const llm = isVibeopsProject ? await buildLlmStatusReport(cwd) : null;
|
|
61
67
|
return {
|
|
62
68
|
vibeopsVersion: VERSION,
|
|
63
69
|
isVibeopsProject,
|
|
64
70
|
projectName: config?.name ?? null,
|
|
71
|
+
clients: getClientsFromConfig(config),
|
|
65
72
|
git: {
|
|
66
73
|
isRepo: git.isRepo,
|
|
67
74
|
branch: git.branch,
|
|
68
75
|
dirty: git.dirty,
|
|
76
|
+
policy: config?.git ? formatGitPolicySummary(config.git) : null,
|
|
69
77
|
},
|
|
70
78
|
counts,
|
|
71
79
|
inProgress: inProgress.map((t) => ({
|
|
@@ -75,6 +83,7 @@ async function buildReport(cwd) {
|
|
|
75
83
|
})),
|
|
76
84
|
focus: focusBlock,
|
|
77
85
|
nextHint: hintToText(hint, cwd, focus),
|
|
86
|
+
llm,
|
|
78
87
|
};
|
|
79
88
|
}
|
|
80
89
|
function printHuman(report) {
|
|
@@ -90,6 +99,9 @@ function printHuman(report) {
|
|
|
90
99
|
return;
|
|
91
100
|
}
|
|
92
101
|
log.info(` ${dim("Project")} ${report.projectName ?? "(unnamed)"}`);
|
|
102
|
+
if (report.clients.length > 0) {
|
|
103
|
+
log.info(` ${dim("Clients")} ${report.clients.join(", ")}`);
|
|
104
|
+
}
|
|
93
105
|
if (report.counts) {
|
|
94
106
|
const c = report.counts;
|
|
95
107
|
log.info(` ${dim("Tasks")} ${c.total} total · ${cyan(String(c.in_progress))} in progress · ${c.done} done`);
|
|
@@ -125,6 +137,9 @@ function printHuman(report) {
|
|
|
125
137
|
log.info(` ${dim("Tree")} ${yellow("dirty")}`);
|
|
126
138
|
else if (report.git.dirty === false)
|
|
127
139
|
log.info(` ${dim("Tree")} ${green("clean")}`);
|
|
140
|
+
if (report.git.policy) {
|
|
141
|
+
log.info(` ${dim("Policy")} ${report.git.policy}`);
|
|
142
|
+
}
|
|
128
143
|
if (report.focus?.taskBranch) {
|
|
129
144
|
const on = report.focus.onTaskBranch === true
|
|
130
145
|
? green("yes")
|
|
@@ -133,6 +148,27 @@ function printHuman(report) {
|
|
|
133
148
|
: dim("—");
|
|
134
149
|
log.info(` ${dim("Task br.")} ${report.focus.taskBranch} · on branch: ${on}`);
|
|
135
150
|
}
|
|
151
|
+
if (report.focus?.mergeRequestUrl) {
|
|
152
|
+
log.info(` ${dim("MR/PR")} ${report.focus.mergeRequestUrl}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (report.llm) {
|
|
156
|
+
log.blank();
|
|
157
|
+
log.info(bold(" LLM"));
|
|
158
|
+
log.info(` ${dim("Preferred")} ${report.llm.preference}`);
|
|
159
|
+
const active = report.llm.active !== null
|
|
160
|
+
? green(`${report.llm.active} (ok)`)
|
|
161
|
+
: report.llm.anyAvailable
|
|
162
|
+
? yellow("(preference not available)")
|
|
163
|
+
: yellow("none");
|
|
164
|
+
log.info(` ${dim("Active")} ${active}`);
|
|
165
|
+
for (const p of report.llm.providers) {
|
|
166
|
+
const summary = p.summary.length > 56 ? `${p.summary.slice(0, 53)}…` : p.summary;
|
|
167
|
+
log.info(` ${dim(p.id.padEnd(14))} ${p.ok ? green("ok") : dim("—")} ${dim(summary)}`);
|
|
168
|
+
}
|
|
169
|
+
if (!report.llm.anyAvailable) {
|
|
170
|
+
log.info(` ${dim("Setup")} ${cyan("vibeops llm connect")}`);
|
|
171
|
+
}
|
|
136
172
|
}
|
|
137
173
|
log.blank();
|
|
138
174
|
log.info(`${dim("Next")} ${report.nextHint}`);
|
|
@@ -1,21 +1,39 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { pathExists, writeText } from "../lib/filesystem.js";
|
|
3
|
+
import { GitConfigError, requireGitConfig } from "../lib/git-config.js";
|
|
3
4
|
import { askInput } from "../lib/inquirer-helpers.js";
|
|
4
5
|
import { bold, cyan, dim, log, yellow } from "../lib/logger.js";
|
|
5
6
|
import { projectPaths } from "../lib/paths.js";
|
|
6
|
-
import { readConfig } from "../lib/config.js";
|
|
7
7
|
import { slugify } from "../lib/slug.js";
|
|
8
8
|
import { fallbackTaskDraft, llmScaffoldTask } from "../lib/task-add-llm.js";
|
|
9
9
|
import { allocateNextTaskNumber, formatTaskId, uniqueTaskPath, } from "../lib/task-scaffold.js";
|
|
10
10
|
import { findBlockingTask, relPath } from "../lib/task-context.js";
|
|
11
11
|
import { startTaskBranch } from "../lib/task-start.js";
|
|
12
12
|
import { loadActionableTasks, readGitContext } from "../lib/task.js";
|
|
13
|
+
async function loadGitConfigOrNull(root, dryRun) {
|
|
14
|
+
try {
|
|
15
|
+
return await requireGitConfig(root);
|
|
16
|
+
}
|
|
17
|
+
catch (e) {
|
|
18
|
+
if (e instanceof GitConfigError) {
|
|
19
|
+
if (dryRun)
|
|
20
|
+
return null;
|
|
21
|
+
log.error(e.message);
|
|
22
|
+
process.exitCode = 1;
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
throw e;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
13
28
|
export async function taskAddCommand(opts = {}) {
|
|
14
29
|
const root = resolve(opts.cwd ?? process.cwd());
|
|
15
30
|
const dryRun = opts.dryRun === true;
|
|
16
31
|
const nonInteractive = opts.nonInteractive === true || dryRun || process.stdin.isTTY !== true;
|
|
17
32
|
log.info(bold("vibeops task add"));
|
|
18
33
|
log.blank();
|
|
34
|
+
const gitCfg = await loadGitConfigOrNull(root, dryRun);
|
|
35
|
+
if (!dryRun && gitCfg === null)
|
|
36
|
+
return;
|
|
19
37
|
const paths = projectPaths(root);
|
|
20
38
|
const blocking = await findBlockingTask(paths, root);
|
|
21
39
|
if (blocking !== null) {
|
|
@@ -59,6 +77,7 @@ export async function taskAddCommand(opts = {}) {
|
|
|
59
77
|
}
|
|
60
78
|
else {
|
|
61
79
|
log.warn("LLM unavailable — using minimal TASK template.");
|
|
80
|
+
log.info(dim(` Run ${cyan("vibeops llm connect")} to set up Codex, Cursor Agent CLI, or OpenAI.`));
|
|
62
81
|
const fb = fallbackTaskDraft(taskId, idea);
|
|
63
82
|
title = fb.title;
|
|
64
83
|
slug = fb.slug;
|
|
@@ -67,9 +86,19 @@ export async function taskAddCommand(opts = {}) {
|
|
|
67
86
|
}
|
|
68
87
|
const { filePath } = uniqueTaskPath(paths.docsTasks, taskId, slugify(slug || title), tasks.map((t) => t.filePath));
|
|
69
88
|
const relFile = relPath(root, filePath);
|
|
89
|
+
const integration = gitCfg?.integrationBranch ?? "integration";
|
|
70
90
|
if (dryRun) {
|
|
71
91
|
log.info(`[dry-run] Would create ${bold(taskId)} → ${cyan(relFile)}`);
|
|
72
|
-
log.info(dim(` branch task/${slugify(slug || title).replace(/^(\d+)-/, "$1-")}`));
|
|
92
|
+
log.info(dim(` branch task/${slugify(slug || title).replace(/^(\d+)-/, "$1-")} from ${integration}`));
|
|
93
|
+
if (gitCfg) {
|
|
94
|
+
await startTaskBranch({
|
|
95
|
+
cwd: root,
|
|
96
|
+
taskFile: filePath,
|
|
97
|
+
integrationBranch: gitCfg.integrationBranch,
|
|
98
|
+
remote: gitCfg.remote,
|
|
99
|
+
dryRun: true,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
73
102
|
return;
|
|
74
103
|
}
|
|
75
104
|
if (await pathExists(filePath)) {
|
|
@@ -77,15 +106,13 @@ export async function taskAddCommand(opts = {}) {
|
|
|
77
106
|
}
|
|
78
107
|
await writeText(filePath, markdown);
|
|
79
108
|
log.ok(`Created ${bold(taskId)} → ${cyan(relFile)}`);
|
|
80
|
-
const config = await readConfig(root);
|
|
81
|
-
if (config === null) {
|
|
82
|
-
log.warn("No .vibeops.json — run vibeops init for full template.");
|
|
83
|
-
}
|
|
84
109
|
log.blank();
|
|
85
110
|
log.step("Starting task branch…");
|
|
86
111
|
const started = await startTaskBranch({
|
|
87
112
|
cwd: root,
|
|
88
113
|
taskFile: filePath,
|
|
114
|
+
integrationBranch: gitCfg.integrationBranch,
|
|
115
|
+
remote: gitCfg.remote,
|
|
89
116
|
allowDirty: true,
|
|
90
117
|
});
|
|
91
118
|
if (!started) {
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
|
-
import { askYesNo } from "../lib/inquirer-helpers.js";
|
|
3
2
|
import { readText } from "../lib/filesystem.js";
|
|
4
3
|
import { bold, cyan, dim, green, log, yellow } from "../lib/logger.js";
|
|
5
4
|
import { projectPaths } from "../lib/paths.js";
|
|
6
5
|
import { resolveTask, taskNotFoundMessage } from "../lib/resolve-task.js";
|
|
7
|
-
import { mergeTaskBranch } from "../lib/task-merge.js";
|
|
8
6
|
import { applyTaskDoneMemory, fallbackResultSections, llmCompleteTaskDone, writeTaskResultSections, } from "../lib/task-done-llm.js";
|
|
9
7
|
import { relPath } from "../lib/task-context.js";
|
|
10
8
|
import { gitAddPaths, listWorkingTreeRelPaths, partitionPathsForAutoCommit, readGitInfo, runGit, } from "../lib/git.js";
|
|
11
9
|
import { hasNonEmptySection, markGitContextDone, parseTaskFilename, readGitContext, readTaskFile, updateInlineStatus, } from "../lib/task.js";
|
|
10
|
+
import { finishTaskWithPullRequest } from "../lib/task-push-pr.js";
|
|
12
11
|
function commitMessageFor(taskId, title) {
|
|
13
12
|
const slug = title.replace(/^TASK-\d+\s*[:\-]\s*/i, "").trim() || taskId;
|
|
14
13
|
return `feat(${taskId.toLowerCase()}): ${slug}`;
|
|
@@ -36,7 +35,6 @@ export async function taskDoneCommand(taskRef, options = {}) {
|
|
|
36
35
|
const cwd = resolve(options.cwd ?? process.cwd());
|
|
37
36
|
const paths = projectPaths(cwd);
|
|
38
37
|
const dryRun = options.dryRun === true;
|
|
39
|
-
const interactive = !dryRun && process.stdin.isTTY === true && options.yes !== true;
|
|
40
38
|
const resolved = await resolveTask(paths, cwd, taskRef);
|
|
41
39
|
if (resolved === null) {
|
|
42
40
|
log.error(taskNotFoundMessage(taskRef));
|
|
@@ -98,44 +96,42 @@ export async function taskDoneCommand(taskRef, options = {}) {
|
|
|
98
96
|
if (!resultOk || !testOk) {
|
|
99
97
|
log.info(" · LLM fill Result / Test Result + patch docs/project/*");
|
|
100
98
|
}
|
|
101
|
-
log.info(" ·
|
|
102
|
-
log.info(" ·
|
|
99
|
+
log.info(" · commit if dirty, push task branch, open MR/PR (unless --no-pr)");
|
|
100
|
+
log.info(" · Status → Done after push/MR succeed");
|
|
101
|
+
await finishTaskWithPullRequest({
|
|
102
|
+
cwd,
|
|
103
|
+
taskFile,
|
|
104
|
+
dryRun: true,
|
|
105
|
+
skipPr: options.noPr === true,
|
|
106
|
+
});
|
|
103
107
|
return;
|
|
104
108
|
}
|
|
105
|
-
await updateInlineStatus(taskFile, "done");
|
|
106
|
-
await markGitContextDone(taskFile);
|
|
107
|
-
log.ok("Status → Done");
|
|
108
109
|
const parts = parseTaskFilename(taskFile);
|
|
109
110
|
const commitMsg = commitMessageFor(parts.id, meta.title);
|
|
110
111
|
await tryCommitDirty(cwd, commitMsg, false);
|
|
111
112
|
const gitCtx = await readGitContext(taskFile);
|
|
112
|
-
if (
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const mergeResult = await mergeTaskBranch({
|
|
123
|
-
cwd,
|
|
124
|
-
taskId: meta.id,
|
|
125
|
-
taskTitle: meta.title,
|
|
126
|
-
gitCtx,
|
|
127
|
-
allowDirty: options.allowDirty,
|
|
128
|
-
});
|
|
129
|
-
if (!mergeResult.ok)
|
|
130
|
-
process.exitCode = 1;
|
|
113
|
+
if (gitCtx !== null) {
|
|
114
|
+
const prResult = await finishTaskWithPullRequest({
|
|
115
|
+
cwd,
|
|
116
|
+
taskFile,
|
|
117
|
+
skipPr: options.noPr === true,
|
|
118
|
+
});
|
|
119
|
+
if (!prResult.ok) {
|
|
120
|
+
log.error("TASK left In Progress — fix push/MR, then rerun task done.");
|
|
121
|
+
process.exitCode = 1;
|
|
122
|
+
return;
|
|
131
123
|
}
|
|
132
|
-
|
|
133
|
-
log.info(dim("
|
|
124
|
+
if (prResult.mergeRequestUrl) {
|
|
125
|
+
log.info(` ${dim("MR/PR")} ${prResult.mergeRequestUrl}`);
|
|
126
|
+
log.info(dim("Merge on GitHub/GitLab — CI deploys after merge."));
|
|
134
127
|
}
|
|
135
128
|
}
|
|
136
|
-
else
|
|
137
|
-
log.info(dim("No Git Context —
|
|
129
|
+
else {
|
|
130
|
+
log.info(dim("No Git Context — push/MR skipped."));
|
|
138
131
|
}
|
|
132
|
+
await updateInlineStatus(taskFile, "done");
|
|
133
|
+
await markGitContextDone(taskFile);
|
|
134
|
+
log.ok("Status → Done");
|
|
139
135
|
log.blank();
|
|
140
136
|
log.info(`Next: ${cyan("vibeops task add")} or ${cyan("vibeops status")}`);
|
|
141
137
|
}
|
package/dist/lib/config.js
CHANGED
|
@@ -1,8 +1,57 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
-
import { readTextOrNull, writeText } from "./filesystem.js";
|
|
2
|
+
import { pathExists, readTextOrNull, writeText } from "./filesystem.js";
|
|
3
3
|
import { VIBEOPS_CONFIG_FILE } from "./paths.js";
|
|
4
4
|
import { VERSION } from "../version.js";
|
|
5
|
-
import { VIBEOPS_CONFIG_SCHEMA_VERSION } from "../types/config.js";
|
|
5
|
+
import { VIBEOPS_CONFIG_SCHEMA_VERSION, } from "../types/config.js";
|
|
6
|
+
import { isVibeopsClientId } from "./init-clients.js";
|
|
7
|
+
function parseClientsBlock(raw) {
|
|
8
|
+
if (!Array.isArray(raw))
|
|
9
|
+
return undefined;
|
|
10
|
+
const out = [];
|
|
11
|
+
for (const item of raw) {
|
|
12
|
+
if (typeof item === "string" && isVibeopsClientId(item) && !out.includes(item)) {
|
|
13
|
+
out.push(item);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return out.length > 0 ? out : undefined;
|
|
17
|
+
}
|
|
18
|
+
export function getClientsFromConfig(config) {
|
|
19
|
+
if (config?.clients && config.clients.length > 0)
|
|
20
|
+
return [...config.clients];
|
|
21
|
+
return ["cursor"];
|
|
22
|
+
}
|
|
23
|
+
export async function isVibeopsProject(root) {
|
|
24
|
+
if ((await readConfig(root)) !== null)
|
|
25
|
+
return true;
|
|
26
|
+
return pathExists(join(root, VIBEOPS_CONFIG_FILE));
|
|
27
|
+
}
|
|
28
|
+
function parseGitBlock(raw) {
|
|
29
|
+
if (raw === null || raw === undefined || typeof raw !== "object" || Array.isArray(raw)) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
const o = raw;
|
|
33
|
+
const remote = typeof o.remote === "string" && o.remote.length > 0 ? o.remote : "origin";
|
|
34
|
+
const host = o.host === "github" || o.host === "gitlab" ? o.host : undefined;
|
|
35
|
+
const integrationBranch = typeof o.integrationBranch === "string" && o.integrationBranch.length > 0
|
|
36
|
+
? o.integrationBranch
|
|
37
|
+
: undefined;
|
|
38
|
+
const productionBranch = typeof o.productionBranch === "string" && o.productionBranch.length > 0
|
|
39
|
+
? o.productionBranch
|
|
40
|
+
: undefined;
|
|
41
|
+
if (!host || !integrationBranch || !productionBranch)
|
|
42
|
+
return undefined;
|
|
43
|
+
return { remote, host, integrationBranch, productionBranch };
|
|
44
|
+
}
|
|
45
|
+
function parseLlmBlock(raw) {
|
|
46
|
+
if (raw === null || raw === undefined || typeof raw !== "object" || Array.isArray(raw)) {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
const p = raw.provider;
|
|
50
|
+
if (p === "auto" || p === "codex-oauth" || p === "cursor-agent" || p === "openai") {
|
|
51
|
+
return { provider: p };
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
6
55
|
export async function readConfig(root) {
|
|
7
56
|
const text = await readTextOrNull(join(root, VIBEOPS_CONFIG_FILE));
|
|
8
57
|
if (text === null)
|
|
@@ -13,11 +62,17 @@ export async function readConfig(root) {
|
|
|
13
62
|
typeof parsed.vibeopsVersion === "string" &&
|
|
14
63
|
typeof parsed.createdAt === "string" &&
|
|
15
64
|
parsed.schemaVersion === VIBEOPS_CONFIG_SCHEMA_VERSION) {
|
|
65
|
+
const llm = parseLlmBlock(parsed.llm);
|
|
66
|
+
const git = parseGitBlock(parsed.git);
|
|
67
|
+
const clients = parseClientsBlock(parsed.clients) ?? ["cursor"];
|
|
16
68
|
return {
|
|
17
69
|
name: parsed.name,
|
|
18
70
|
vibeopsVersion: parsed.vibeopsVersion,
|
|
19
71
|
schemaVersion: VIBEOPS_CONFIG_SCHEMA_VERSION,
|
|
20
72
|
createdAt: parsed.createdAt,
|
|
73
|
+
clients,
|
|
74
|
+
...(git ? { git } : {}),
|
|
75
|
+
...(llm ? { llm } : {}),
|
|
21
76
|
};
|
|
22
77
|
}
|
|
23
78
|
return null;
|
|
@@ -26,12 +81,15 @@ export async function readConfig(root) {
|
|
|
26
81
|
return null;
|
|
27
82
|
}
|
|
28
83
|
}
|
|
29
|
-
export function buildConfig(name, nowIso) {
|
|
84
|
+
export function buildConfig(name, clients, git, existing, nowIso) {
|
|
30
85
|
return {
|
|
31
86
|
name,
|
|
32
87
|
vibeopsVersion: VERSION,
|
|
33
88
|
schemaVersion: VIBEOPS_CONFIG_SCHEMA_VERSION,
|
|
34
|
-
createdAt: nowIso ?? new Date().toISOString(),
|
|
89
|
+
createdAt: existing?.createdAt ?? nowIso ?? new Date().toISOString(),
|
|
90
|
+
clients,
|
|
91
|
+
git,
|
|
92
|
+
llm: existing?.llm ?? { provider: "auto" },
|
|
35
93
|
};
|
|
36
94
|
}
|
|
37
95
|
export function configToJson(config) {
|
|
@@ -40,3 +98,25 @@ export function configToJson(config) {
|
|
|
40
98
|
export async function writeConfig(root, config) {
|
|
41
99
|
await writeText(join(root, VIBEOPS_CONFIG_FILE), configToJson(config));
|
|
42
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Merge `llm.provider` into existing `.vibeops.json` (preserves other keys).
|
|
103
|
+
*/
|
|
104
|
+
export async function setLlmProviderPreference(root, provider) {
|
|
105
|
+
const path = join(root, VIBEOPS_CONFIG_FILE);
|
|
106
|
+
const text = await readTextOrNull(path);
|
|
107
|
+
if (text === null) {
|
|
108
|
+
throw new Error(`${VIBEOPS_CONFIG_FILE} not found — run vibeops init first.`);
|
|
109
|
+
}
|
|
110
|
+
let data;
|
|
111
|
+
try {
|
|
112
|
+
data = JSON.parse(text);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
throw new Error(`${VIBEOPS_CONFIG_FILE} is not valid JSON.`);
|
|
116
|
+
}
|
|
117
|
+
const prevLlm = typeof data.llm === "object" && data.llm !== null && !Array.isArray(data.llm)
|
|
118
|
+
? data.llm
|
|
119
|
+
: {};
|
|
120
|
+
data.llm = { ...prevLlm, provider };
|
|
121
|
+
await writeText(path, `${JSON.stringify(data, null, 2)}\n`);
|
|
122
|
+
}
|
package/dist/lib/env.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { VIBEOPS_ENV_FILE } from "./paths.js";
|
|
4
|
+
/**
|
|
5
|
+
* Loads `KEY=value` pairs from `.vibeops.env` without overwriting existing `process.env`.
|
|
6
|
+
*/
|
|
7
|
+
export async function loadVibeopsEnv(cwd) {
|
|
8
|
+
const path = join(resolve(cwd), VIBEOPS_ENV_FILE);
|
|
9
|
+
try {
|
|
10
|
+
const text = await readFile(path, "utf-8");
|
|
11
|
+
for (const line of text.split("\n")) {
|
|
12
|
+
const t = line.trim();
|
|
13
|
+
if (t.length === 0 || t.startsWith("#"))
|
|
14
|
+
continue;
|
|
15
|
+
const eq = t.indexOf("=");
|
|
16
|
+
if (eq <= 0)
|
|
17
|
+
continue;
|
|
18
|
+
const key = t.slice(0, eq).trim();
|
|
19
|
+
let val = t.slice(eq + 1).trim();
|
|
20
|
+
if ((val.startsWith('"') && val.endsWith('"')) ||
|
|
21
|
+
(val.startsWith("'") && val.endsWith("'"))) {
|
|
22
|
+
val = val.slice(1, -1);
|
|
23
|
+
}
|
|
24
|
+
if (key.length > 0 && process.env[key] === undefined) {
|
|
25
|
+
process.env[key] = val;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
/* missing or unreadable — ok */
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { readConfig } from "./config.js";
|
|
2
|
+
export class GitConfigError extends Error {
|
|
3
|
+
constructor(message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = "GitConfigError";
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
/** Load `.vibeops.json` git block or throw with re-init guidance. */
|
|
9
|
+
export async function requireGitConfig(cwd) {
|
|
10
|
+
const config = await readConfig(cwd);
|
|
11
|
+
if (!config) {
|
|
12
|
+
throw new GitConfigError("No .vibeops.json found. Run vibeops init in this project first.");
|
|
13
|
+
}
|
|
14
|
+
if (!config.git?.integrationBranch || !config.git?.productionBranch) {
|
|
15
|
+
throw new GitConfigError("Missing git policy in .vibeops.json. Re-run vibeops init to set integration and production branches.");
|
|
16
|
+
}
|
|
17
|
+
if (!config.git.host || !config.git.remote) {
|
|
18
|
+
throw new GitConfigError("Incomplete git config in .vibeops.json. Re-run vibeops init.");
|
|
19
|
+
}
|
|
20
|
+
return config.git;
|
|
21
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Infer Git host from a remote URL. */
|
|
2
|
+
export function detectGitHost(remoteUrl) {
|
|
3
|
+
const u = remoteUrl.trim().toLowerCase();
|
|
4
|
+
if (u.includes("github.com"))
|
|
5
|
+
return "github";
|
|
6
|
+
if (u.includes("gitlab"))
|
|
7
|
+
return "gitlab";
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
export function mergeRequestLabel(host) {
|
|
11
|
+
return host === "gitlab" ? "Merge request" : "Pull request";
|
|
12
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { select } from "@inquirer/prompts";
|
|
2
|
+
import { askInput } from "./inquirer-helpers.js";
|
|
3
|
+
export const GIT_POLICY_PRESETS = [
|
|
4
|
+
{
|
|
5
|
+
id: "gitflow",
|
|
6
|
+
label: "GitFlow lite — develop (integration) + main (production)",
|
|
7
|
+
integrationBranch: "develop",
|
|
8
|
+
productionBranch: "main",
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
id: "trunk",
|
|
12
|
+
label: "Trunk — main only (integration and production = main)",
|
|
13
|
+
integrationBranch: "main",
|
|
14
|
+
productionBranch: "main",
|
|
15
|
+
},
|
|
16
|
+
];
|
|
17
|
+
export function parseGitPolicyArg(raw) {
|
|
18
|
+
if (raw === undefined || raw.trim().length === 0)
|
|
19
|
+
return null;
|
|
20
|
+
const t = raw.trim().toLowerCase();
|
|
21
|
+
if (t === "gitflow" || t === "develop-main")
|
|
22
|
+
return "gitflow";
|
|
23
|
+
if (t === "trunk" || t === "main")
|
|
24
|
+
return "trunk";
|
|
25
|
+
if (t === "custom")
|
|
26
|
+
return "custom";
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
export function resolvePreset(id) {
|
|
30
|
+
return GIT_POLICY_PRESETS.find((p) => p.id === id) ?? null;
|
|
31
|
+
}
|
|
32
|
+
export async function askGitPolicy() {
|
|
33
|
+
const preset = await select({
|
|
34
|
+
message: "Branch policy",
|
|
35
|
+
choices: [
|
|
36
|
+
...GIT_POLICY_PRESETS.map((p) => ({ name: p.label, value: p.id })),
|
|
37
|
+
{ name: "Custom branch names", value: "custom" },
|
|
38
|
+
],
|
|
39
|
+
loop: false,
|
|
40
|
+
});
|
|
41
|
+
if (preset !== "custom") {
|
|
42
|
+
const p = resolvePreset(preset);
|
|
43
|
+
return {
|
|
44
|
+
integrationBranch: p.integrationBranch,
|
|
45
|
+
productionBranch: p.productionBranch,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const integrationBranch = await askInput({
|
|
49
|
+
message: "Integration branch (task branches start here; MR/PR target)",
|
|
50
|
+
nonInteractive: false,
|
|
51
|
+
default: "develop",
|
|
52
|
+
required: true,
|
|
53
|
+
});
|
|
54
|
+
const productionBranch = await askInput({
|
|
55
|
+
message: "Production branch (releases / deploy; merge manually)",
|
|
56
|
+
nonInteractive: false,
|
|
57
|
+
default: "main",
|
|
58
|
+
required: true,
|
|
59
|
+
});
|
|
60
|
+
return { integrationBranch, productionBranch };
|
|
61
|
+
}
|
|
62
|
+
export function formatGitPolicySummary(git) {
|
|
63
|
+
if (git.integrationBranch === git.productionBranch) {
|
|
64
|
+
return `${git.integrationBranch} (integration + production)`;
|
|
65
|
+
}
|
|
66
|
+
return `integration=${git.integrationBranch}, production=${git.productionBranch}`;
|
|
67
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { askInput } from "./inquirer-helpers.js";
|
|
4
|
+
import { detectGitHost } from "./git-host.js";
|
|
5
|
+
import { gitRemoteUrl, runGit } from "./git.js";
|
|
6
|
+
import { dim, log } from "./logger.js";
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
async function probeGh() {
|
|
9
|
+
try {
|
|
10
|
+
await execFileAsync("gh", ["auth", "status"], { maxBuffer: 1024 * 1024 });
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
async function probeGlab() {
|
|
18
|
+
try {
|
|
19
|
+
await execFileAsync("glab", ["auth", "status"], { maxBuffer: 1024 * 1024 });
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
async function createGithubRepo(cwd, name) {
|
|
27
|
+
const { stdout } = await execFileAsync("gh",
|
|
28
|
+
// gh requires an explicit visibility flag when non-interactive.
|
|
29
|
+
["repo", "create", name, "--source=.", "--remote=origin", "--push", "--private"], { cwd, maxBuffer: 8 * 1024 * 1024 });
|
|
30
|
+
const match = stdout.match(/https:\/\/github\.com\/[^\s]+/);
|
|
31
|
+
if (match)
|
|
32
|
+
return match[0].replace(/\.git$/, "") + ".git";
|
|
33
|
+
const url = await gitRemoteUrl(cwd, "origin");
|
|
34
|
+
if (url)
|
|
35
|
+
return url;
|
|
36
|
+
throw new Error("gh repo create finished but origin URL could not be read.");
|
|
37
|
+
}
|
|
38
|
+
async function createGitlabRepo(cwd, name, defaultBranch) {
|
|
39
|
+
await execFileAsync("glab", ["repo", "create", name, "--remote=origin", "--defaultBranch", defaultBranch], { cwd, maxBuffer: 8 * 1024 * 1024 });
|
|
40
|
+
const url = await gitRemoteUrl(cwd, "origin");
|
|
41
|
+
if (url)
|
|
42
|
+
return url;
|
|
43
|
+
throw new Error("glab repo create finished but origin URL could not be read.");
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Ensures `origin` (or named remote) exists. Interactive path can create via gh/glab or manual URL.
|
|
47
|
+
*/
|
|
48
|
+
export async function ensureOriginRemote(opts) {
|
|
49
|
+
const remote = opts.remoteName ?? "origin";
|
|
50
|
+
const existing = await gitRemoteUrl(opts.cwd, remote);
|
|
51
|
+
if (existing) {
|
|
52
|
+
const host = detectGitHost(existing);
|
|
53
|
+
if (!host) {
|
|
54
|
+
log.warn(`Remote ${remote} URL is not GitHub or GitLab: ${existing}`);
|
|
55
|
+
if (opts.allowMissing)
|
|
56
|
+
return null;
|
|
57
|
+
throw new Error("Only GitHub and GitLab remotes are supported.");
|
|
58
|
+
}
|
|
59
|
+
return { remote, url: existing, host };
|
|
60
|
+
}
|
|
61
|
+
if (opts.dryRun) {
|
|
62
|
+
log.info(dim(`would configure git remote ${remote}`));
|
|
63
|
+
return { remote, url: "(dry-run)", host: "github" };
|
|
64
|
+
}
|
|
65
|
+
if (opts.allowMissing) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
if (opts.nonInteractive) {
|
|
69
|
+
throw new Error(`Git remote "${remote}" is not configured. Add it with: git remote add ${remote} <url>`);
|
|
70
|
+
}
|
|
71
|
+
log.blank();
|
|
72
|
+
log.info("Remote origin is required for push and merge requests.");
|
|
73
|
+
const ghOk = await probeGh();
|
|
74
|
+
const glabOk = await probeGlab();
|
|
75
|
+
if (ghOk) {
|
|
76
|
+
const name = await askInput({
|
|
77
|
+
message: "GitHub repository name (owner/repo or name for gh to create)",
|
|
78
|
+
nonInteractive: false,
|
|
79
|
+
required: true,
|
|
80
|
+
});
|
|
81
|
+
log.info(dim("Creating GitHub repository via gh…"));
|
|
82
|
+
const url = await createGithubRepo(opts.cwd, name);
|
|
83
|
+
return { remote, url, host: "github" };
|
|
84
|
+
}
|
|
85
|
+
if (glabOk) {
|
|
86
|
+
const name = await askInput({
|
|
87
|
+
message: "GitLab project path (group/project)",
|
|
88
|
+
nonInteractive: false,
|
|
89
|
+
required: true,
|
|
90
|
+
});
|
|
91
|
+
log.info(dim("Creating GitLab project via glab…"));
|
|
92
|
+
const defaultBranch = typeof opts.defaultBranch === "string" && opts.defaultBranch.length > 0
|
|
93
|
+
? opts.defaultBranch
|
|
94
|
+
: "main";
|
|
95
|
+
const url = await createGitlabRepo(opts.cwd, name, defaultBranch);
|
|
96
|
+
return { remote, url, host: "gitlab" };
|
|
97
|
+
}
|
|
98
|
+
const url = await askInput({
|
|
99
|
+
message: `Remote URL for ${remote} (https://github.com/… or https://gitlab.com/…)`,
|
|
100
|
+
nonInteractive: false,
|
|
101
|
+
required: true,
|
|
102
|
+
});
|
|
103
|
+
await runGit(opts.cwd, ["remote", "add", remote, url.trim()]);
|
|
104
|
+
const host = detectGitHost(url);
|
|
105
|
+
if (!host)
|
|
106
|
+
throw new Error("Only GitHub and GitLab HTTPS/SSH URLs are supported.");
|
|
107
|
+
return { remote, url: url.trim(), host };
|
|
108
|
+
}
|