@goodtek/vibeops 2.4.4 → 2.5.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 +12 -0
- package/README.md +10 -10
- package/dist/cli.js +4 -26
- package/dist/commands/init.js +43 -21
- package/dist/commands/status.js +91 -72
- package/dist/commands/task-merge.js +7 -3
- package/dist/commands/task-release.js +5 -2
- package/dist/lib/git-host-cli.js +115 -0
- package/dist/lib/git-remote.js +177 -59
- package/dist/lib/task-context.js +28 -16
- package/dist/lib/task-push-pr.js +6 -2
- package/dist/lib/task-reship.js +1 -1
- package/package.json +14 -12
- package/templates/core/AGENTS.md +1 -1
- package/templates/core/docs/project/06-decisions.md +1 -1
- package/dist/commands/task-reship.js +0 -21
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,18 @@ All notable changes to VibeOps are documented here.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 2.5.0 - 2026-07-19
|
|
8
|
+
|
|
9
|
+
### Breaking
|
|
10
|
+
|
|
11
|
+
- **Removed `vibeops task reship`.** Use `vibeops task ship` (state-aware). After merge, confirm or pass `--new-cycle`. Hidden flag `ship --reship` remains as an alias for `--new-cycle`.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- **`vibeops init` remote setup:** ask GitHub / GitLab / Skip, then Create or Connect. Soft-gate when `gh`/`glab` is missing (install hints, optional `brew install` with consent, or manual URL).
|
|
16
|
+
- **`vibeops status`:** human output is a **Now / Next** card (focus, checklist, PR, next command); details (project, counts, LLM) move to a dim footer.
|
|
17
|
+
- Shared **host CLI install/login hints** for ship / merge / release when the tool is missing.
|
|
18
|
+
|
|
7
19
|
## 2.4.4 - 2026-06-25
|
|
8
20
|
|
|
9
21
|
### Fixed
|
package/README.md
CHANGED
|
@@ -15,22 +15,23 @@ VibeOps bootstraps an **agent-friendly repo**, starts numbered **TASK** files on
|
|
|
15
15
|
| `vibeops task add` | New `TASK-NNN` file + task branch |
|
|
16
16
|
| `vibeops task del` | Cancel TASK before merge (md + branch + close open MR) |
|
|
17
17
|
| `vibeops task ship` | State-aware submit: open a new PR · update the open PR · start a new PR cycle after merge (Status → **Shipped**) |
|
|
18
|
-
| `vibeops task reship` | **Deprecated** alias for `task ship --new-cycle` |
|
|
19
18
|
| `vibeops task merge` | Merge TASK MR/PR into integration branch (default: squash) |
|
|
20
19
|
| `vibeops task sync` | After merge: ff-only integration pull, delete task branches (no TASK md edits) |
|
|
21
20
|
| `vibeops task release` | Release PR: integration → production (GitFlow) |
|
|
22
21
|
| `vibeops pull` | Fetch remote + update integration branch (e.g. develop) |
|
|
23
|
-
| `vibeops status` |
|
|
22
|
+
| `vibeops status` | Now / Next briefing: focus TASK, Git, what to run next |
|
|
24
23
|
| `vibeops llm` | Connect LLM providers (`connect` · `status` · `use`) |
|
|
25
24
|
|
|
26
25
|
## Init
|
|
27
26
|
|
|
27
|
+
Interactive init asks which agents to install, Git branch policy, then **where the remote lives** (GitHub / GitLab / skip) and whether to **create** or **connect** a repository. If `gh`/`glab` is missing, init prints install hints (optional `brew install` with consent) or accepts a URL — it does not block the rest of VibeOps.
|
|
28
|
+
|
|
28
29
|
```bash
|
|
29
|
-
# Interactive: pick agents (≥1), then Git
|
|
30
|
+
# Interactive: pick agents (≥1), then Git + remote host
|
|
30
31
|
vibeops init
|
|
31
32
|
|
|
32
33
|
# Non-interactive (GitFlow: develop + main, origin required unless CI)
|
|
33
|
-
vibeops init --clients cursor,claude,codex --git --initial-commit --git-policy gitflow
|
|
34
|
+
vibeops init --clients cursor,claude,codex --git --initial-commit --git-policy gitflow --allow-no-remote
|
|
34
35
|
|
|
35
36
|
# Re-init templates on an existing project
|
|
36
37
|
vibeops init --clients cursor --yes
|
|
@@ -107,11 +108,10 @@ pnpm install && pnpm build && pnpm smoke
|
|
|
107
108
|
|
|
108
109
|
## Flags (common)
|
|
109
110
|
|
|
110
|
-
- **`init`**: `--clients`, `--yes`, `--dry-run`, `--force`, `--git`, `--initial-commit`, `--git-policy gitflow|trunk`, `--integration-branch`, `--production-branch`, `--allow-no-remote`, `--cwd`
|
|
111
|
+
- **`init`**: `--clients`, `--yes`, `--dry-run`, `--force`, `--git`, `--initial-commit`, `--git-policy gitflow|trunk`, `--integration-branch`, `--production-branch`, `--git-host github|gitlab`, `--allow-no-remote`, `--cwd`
|
|
111
112
|
- **`task add`**: `--dry-run`, `--non-interactive --idea "…"`
|
|
112
113
|
- **`task del`**: `--dry-run`, `--force`, `--no-remote-delete`, `--no-close-mr`
|
|
113
|
-
- **`task ship`**: `-m/--message`, `--new-cycle
|
|
114
|
-
- **`task reship`** (deprecated): `--dry-run`, `--no-pr`, `--no-integrate`, `--recreate-branch`, `--skip-llm`, `--allow-open-mr` → delegates to `task ship --new-cycle`
|
|
114
|
+
- **`task ship`**: `-m/--message`, `--new-cycle`, `--no-commit`, `--dry-run`, `--no-pr`, `--non-interactive`, `--allow-open-mr`, `--no-integrate`, `--recreate-branch`, `--skip-llm`
|
|
115
115
|
- **`task merge`**: `--dry-run`, `--merge`, `--rebase`
|
|
116
116
|
- **`task sync`**: `--dry-run`, `--no-remote-delete`, `--force`
|
|
117
117
|
- **`task release`**: `--dry-run`, `--no-merge`, `--merge`, `--rebase`
|
|
@@ -120,14 +120,14 @@ pnpm install && pnpm build && pnpm smoke
|
|
|
120
120
|
|
|
121
121
|
## Git
|
|
122
122
|
|
|
123
|
-
- **Init** records branch policy in `.vibeops.json` (`integrationBranch`, `productionBranch`, `host`).
|
|
123
|
+
- **Init** records branch policy in `.vibeops.json` (`integrationBranch`, `productionBranch`, `host`). Interactive remote setup: ask GitHub/GitLab, create or connect; soft-gate when `gh`/`glab` is missing.
|
|
124
124
|
- **`task add`**: `task/<slug>` from **integration** (e.g. `develop`).
|
|
125
|
-
- **`task ship`** (state-aware): **no PR** → commit + ship metadata (Status **Shipped**) → push → open MR/PR; **open PR** → commit + push the same branch, CI re-runs, no new PR; **merged PR** → new PR cycle (carries uncommitted work onto the task branch, integrates `develop`, opens a **new** PR). Commit messages are TASK-id-scoped (`feat(task-001): …`). Refuses when HEAD is not the task branch.
|
|
126
|
-
- **`task reship`** *(deprecated)*: alias for `task ship --new-cycle`.
|
|
125
|
+
- **`task ship`** (state-aware): **no PR** → commit + ship metadata (Status **Shipped**) → push → open MR/PR; **open PR** → commit + push the same branch, CI re-runs, no new PR; **merged PR** → new PR cycle (carries uncommitted work onto the task branch, integrates `develop`, opens a **new** PR). Use `--new-cycle` in non-interactive mode. Commit messages are TASK-id-scoped (`feat(task-001): …`). Refuses when HEAD is not the task branch.
|
|
127
126
|
- **`task merge`**: merge MR/PR into integration (CLI or host UI; TASK md unchanged).
|
|
128
127
|
- **`task sync`**: integration ff-only pull → delete `task/*` branches (TASK md unchanged).
|
|
129
128
|
- **`pull`**: fetch + switch to integration branch + `git pull --ff-only` (one command).
|
|
130
129
|
- **`task release`**: integration → production PR + merge (skipped on trunk policy).
|
|
130
|
+
- **`status`**: Now / Next card — focus stage, checklist, PR, and the next command to run.
|
|
131
131
|
- No force-push to shared branches.
|
|
132
132
|
|
|
133
133
|
## License
|
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { Command } from "commander";
|
|
2
|
+
import { Command, Option } from "commander";
|
|
3
3
|
import { pullCommand } from "./commands/pull.js";
|
|
4
4
|
import { initCommand } from "./commands/init.js";
|
|
5
5
|
import { llmConnectCommand, llmStatusCommand, llmUseCommand } from "./commands/llm.js";
|
|
@@ -9,7 +9,6 @@ import { taskDelCommand } from "./commands/task-del.js";
|
|
|
9
9
|
import { taskMergeCommand } from "./commands/task-merge.js";
|
|
10
10
|
import { taskReleaseCommand } from "./commands/task-release.js";
|
|
11
11
|
import { taskShipCommand } from "./commands/task-ship.js";
|
|
12
|
-
import { taskReshipCommand } from "./commands/task-reship.js";
|
|
13
12
|
import { taskSyncCommand } from "./commands/task-sync.js";
|
|
14
13
|
import { loadVibeopsEnv } from "./lib/env.js";
|
|
15
14
|
import { VERSION } from "./version.js";
|
|
@@ -61,7 +60,7 @@ program
|
|
|
61
60
|
});
|
|
62
61
|
const task = program
|
|
63
62
|
.command("task")
|
|
64
|
-
.description("TASK lifecycle (add · del · ship ·
|
|
63
|
+
.description("TASK lifecycle (add · del · ship · merge · sync · release)");
|
|
65
64
|
task
|
|
66
65
|
.command("add")
|
|
67
66
|
.description("Create next TASK-NNN, task branch, In Progress")
|
|
@@ -99,8 +98,8 @@ task
|
|
|
99
98
|
.description("State-aware submit: new PR · update open PR · new PR cycle after merge (Status → Shipped)")
|
|
100
99
|
.option("--dry-run", "Plan only")
|
|
101
100
|
.option("-m, --message <msg>", "Commit message (TASK id auto-prefixed; LLM/prompt when omitted)")
|
|
102
|
-
.option("--new-cycle", "Allow a new PR cycle after merge without prompting
|
|
103
|
-
.
|
|
101
|
+
.option("--new-cycle", "Allow a new PR cycle after merge without prompting")
|
|
102
|
+
.addOption(new Option("--reship", "Alias for --new-cycle").hideHelp())
|
|
104
103
|
.option("--no-commit", "Push already-committed changes only (skip staging/commit)")
|
|
105
104
|
.option("--no-pr", "Push only; skip creating MR/PR")
|
|
106
105
|
.option("--non-interactive", "CI: never prompt")
|
|
@@ -124,27 +123,6 @@ task
|
|
|
124
123
|
cwd: opts.cwd,
|
|
125
124
|
});
|
|
126
125
|
});
|
|
127
|
-
task
|
|
128
|
-
.command("reship [taskRef]")
|
|
129
|
-
.description("Deprecated alias for `task ship --new-cycle` (start a new PR cycle after merge)")
|
|
130
|
-
.option("--dry-run", "Plan only")
|
|
131
|
-
.option("--no-pr", "Push only; skip creating MR/PR")
|
|
132
|
-
.option("--no-integrate", "Skip merging integration branch into task branch")
|
|
133
|
-
.option("--recreate-branch", "Create task branch from integration instead of reusing local/remote ref")
|
|
134
|
-
.option("--skip-llm", "Do not run LLM for Result / Test Result")
|
|
135
|
-
.option("--allow-open-mr", "Update the open MR/PR with new commits (merge before reship for a new PR)")
|
|
136
|
-
.option("--cwd <path>", "Target directory")
|
|
137
|
-
.action(async (taskRef, opts) => {
|
|
138
|
-
await taskReshipCommand(taskRef, {
|
|
139
|
-
dryRun: Boolean(opts.dryRun),
|
|
140
|
-
noPr: opts.pr === false,
|
|
141
|
-
noIntegrate: opts.integrate === false,
|
|
142
|
-
recreateBranch: Boolean(opts.recreateBranch),
|
|
143
|
-
skipLlm: Boolean(opts.skipLlm),
|
|
144
|
-
allowOpenMr: Boolean(opts.allowOpenMr),
|
|
145
|
-
cwd: opts.cwd,
|
|
146
|
-
});
|
|
147
|
-
});
|
|
148
126
|
task
|
|
149
127
|
.command("merge [taskRef]")
|
|
150
128
|
.description("Merge TASK MR/PR into integration branch (default: squash)")
|
package/dist/commands/init.js
CHANGED
|
@@ -108,30 +108,41 @@ async function resolveBranchPolicy(options, interactive) {
|
|
|
108
108
|
}
|
|
109
109
|
return { integrationBranch: "develop", productionBranch: "main" };
|
|
110
110
|
}
|
|
111
|
-
function printNextSteps(clients, git) {
|
|
111
|
+
function printNextSteps(clients, git, opts) {
|
|
112
112
|
log.blank();
|
|
113
113
|
log.info("Next steps:");
|
|
114
|
-
|
|
114
|
+
let n = 1;
|
|
115
|
+
log.info(` ${n++}. Read AGENTS.md`);
|
|
115
116
|
if (clients.includes("cursor")) {
|
|
116
|
-
log.info(
|
|
117
|
+
log.info(` ${n++}. Cursor: @docs/tasks/TASK-001-….md in Ask, then Agent (+ /plan-task, /implement-task)`);
|
|
117
118
|
}
|
|
118
|
-
if (clients.includes("claude")) {
|
|
119
|
-
log.info(
|
|
119
|
+
else if (clients.includes("claude")) {
|
|
120
|
+
log.info(` ${n++}. Claude Code: open TASK file; use /plan-task then /implement-task`);
|
|
120
121
|
}
|
|
121
|
-
if (clients.includes("codex")) {
|
|
122
|
-
log.info(
|
|
122
|
+
else if (clients.includes("codex")) {
|
|
123
|
+
log.info(` ${n++}. Codex: open TASK file; use $plan-task / $implement-task when needed`);
|
|
123
124
|
}
|
|
124
|
-
|
|
125
|
-
log.info(
|
|
125
|
+
else {
|
|
126
|
+
log.info(` ${n++}. Open the current TASK file in your agent before coding`);
|
|
127
|
+
}
|
|
128
|
+
log.info(` ${n++}. ${cyan("vibeops llm connect")} — LLM for task add / task ship`);
|
|
129
|
+
if (!opts.hasRemote) {
|
|
130
|
+
log.info(` ${n++}. Add a remote later: ${dim(`git remote add ${git.remote} <url>`)} (or re-run init)`);
|
|
131
|
+
}
|
|
132
|
+
else if (!opts.pushedBaseline) {
|
|
133
|
+
log.info(` ${n++}. Push branches to origin (first time only):`);
|
|
134
|
+
log.info(` ${dim(`git push -u ${git.remote} ${git.productionBranch}`)}`);
|
|
135
|
+
if (git.integrationBranch !== git.productionBranch) {
|
|
136
|
+
log.info(` ${dim(`git push -u ${git.remote} ${git.integrationBranch}`)}`);
|
|
137
|
+
}
|
|
126
138
|
}
|
|
127
|
-
log.info(`
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
139
|
+
log.info(` ${n++}. ${cyan("vibeops task add")} — branches from ${git.integrationBranch} (pulls latest first)`);
|
|
140
|
+
if (opts.hasRemote) {
|
|
141
|
+
log.info(` ${n++}. ${cyan("vibeops task ship")} → merge → sync — TASK lifecycle on ${git.host}`);
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
log.info(` ${n++}. ${cyan("vibeops task ship")} — after origin exists`);
|
|
132
145
|
}
|
|
133
|
-
log.info(` 5. ${cyan("vibeops task add")} — branches from ${git.integrationBranch} (pulls latest first)`);
|
|
134
|
-
log.info(` 6. ${cyan("vibeops task ship")} → merge → sync — TASK lifecycle on ${git.host}`);
|
|
135
146
|
}
|
|
136
147
|
export async function initCommand(options = {}) {
|
|
137
148
|
const dryRun = options.dryRun === true;
|
|
@@ -223,6 +234,7 @@ export async function initCommand(options = {}) {
|
|
|
223
234
|
dryRun,
|
|
224
235
|
nonInteractive: !interactive,
|
|
225
236
|
allowMissing: options.allowNoRemote === true,
|
|
237
|
+
preferredHost: hostFromFlag ?? existingConfig?.git?.host,
|
|
226
238
|
defaultBranch: gitConfig.productionBranch,
|
|
227
239
|
});
|
|
228
240
|
if (remote !== null) {
|
|
@@ -232,14 +244,17 @@ export async function initCommand(options = {}) {
|
|
|
232
244
|
host: remote.host,
|
|
233
245
|
};
|
|
234
246
|
}
|
|
247
|
+
let pushedBaseline = hadRemoteBefore;
|
|
235
248
|
// If we created/configured a new remote during init, push baseline branches so
|
|
236
249
|
// `task add` can always pull latest integration before branching.
|
|
237
|
-
if (!dryRun &&
|
|
250
|
+
if (!dryRun &&
|
|
251
|
+
options.allowNoRemote !== true &&
|
|
252
|
+
!hadRemoteBefore &&
|
|
253
|
+
remote !== null) {
|
|
238
254
|
const remoteName = gitConfig.remote;
|
|
239
255
|
log.blank();
|
|
240
256
|
log.info("Remote bootstrap:");
|
|
241
257
|
try {
|
|
242
|
-
// Ensure local branches are up-to-date with origin if they already exist remotely.
|
|
243
258
|
const prodRemoteExists = await gitRemoteBranchExists(projectRoot, remoteName, gitConfig.productionBranch);
|
|
244
259
|
if (prodRemoteExists) {
|
|
245
260
|
await gitCheckout(projectRoot, gitConfig.productionBranch);
|
|
@@ -258,6 +273,7 @@ export async function initCommand(options = {}) {
|
|
|
258
273
|
await gitPush(projectRoot, remoteName, gitConfig.integrationBranch, true);
|
|
259
274
|
log.ok(`pushed ${gitConfig.integrationBranch} → ${remoteName}`);
|
|
260
275
|
}
|
|
276
|
+
pushedBaseline = true;
|
|
261
277
|
}
|
|
262
278
|
catch (e) {
|
|
263
279
|
const msg = e instanceof Error ? e.message : String(e);
|
|
@@ -267,14 +283,20 @@ export async function initCommand(options = {}) {
|
|
|
267
283
|
if (gitConfig.integrationBranch !== gitConfig.productionBranch) {
|
|
268
284
|
log.info(dim(` git push -u ${remoteName} ${gitConfig.integrationBranch}`));
|
|
269
285
|
}
|
|
286
|
+
pushedBaseline = false;
|
|
270
287
|
}
|
|
271
288
|
}
|
|
272
289
|
if (!dryRun) {
|
|
273
290
|
await writeConfig(projectRoot, buildConfig(name, clients, gitConfig, existingConfig));
|
|
274
|
-
if (
|
|
275
|
-
log.warn(
|
|
291
|
+
if (remote === null) {
|
|
292
|
+
log.warn(options.allowNoRemote === true
|
|
293
|
+
? "No git remote configured (--allow-no-remote). task ship push/MR will fail until origin exists."
|
|
294
|
+
: "No git remote configured. task ship push/MR will fail until origin exists.");
|
|
276
295
|
}
|
|
277
|
-
printNextSteps(clients, gitConfig
|
|
296
|
+
printNextSteps(clients, gitConfig, {
|
|
297
|
+
hasRemote: remote !== null,
|
|
298
|
+
pushedBaseline,
|
|
299
|
+
});
|
|
278
300
|
}
|
|
279
301
|
}
|
|
280
302
|
async function runGitSetup(projectRoot, plan, dryRun) {
|
package/dist/commands/status.js
CHANGED
|
@@ -7,7 +7,7 @@ import { buildLlmStatusReport } from "../lib/llm-status.js";
|
|
|
7
7
|
import { bold, cyan, dim, green, log, yellow } from "../lib/logger.js";
|
|
8
8
|
import { projectPaths } from "../lib/paths.js";
|
|
9
9
|
import { VERSION } from "../version.js";
|
|
10
|
-
import { computeNextHint, hintToText, listInProgressTasks, pickFocusTask, relPath, taskBranchExistsFor, } from "../lib/task-context.js";
|
|
10
|
+
import { computeNextHint, hintToLines, hintToText, listInProgressTasks, pickFocusTask, relPath, taskBranchExistsFor, } from "../lib/task-context.js";
|
|
11
11
|
import { hasOpenTaskMergeRequest, isMergeRequestMerged, resolveTaskMergeRequestUrl, taskNeedsSync, } from "../lib/task-effective-status.js";
|
|
12
12
|
import { countTasks, hasNonEmptySection, isOnTaskBranch, loadActionableTasks, readGitContext, statusDisplay, } from "../lib/task.js";
|
|
13
13
|
import { summarizeGoal } from "../lib/task-summary.js";
|
|
@@ -59,6 +59,8 @@ async function buildReport(cwd) {
|
|
|
59
59
|
taskBranch,
|
|
60
60
|
taskBranchExists: branchExists,
|
|
61
61
|
mergeRequestUrl,
|
|
62
|
+
mergeRequestMerged,
|
|
63
|
+
needsSync,
|
|
62
64
|
};
|
|
63
65
|
}
|
|
64
66
|
const hint = computeNextHint({
|
|
@@ -85,6 +87,8 @@ async function buildReport(cwd) {
|
|
|
85
87
|
branch: git.branch,
|
|
86
88
|
dirty: git.dirty,
|
|
87
89
|
policy: config?.git ? formatGitPolicySummary(config.git) : null,
|
|
90
|
+
host: config?.git?.host ?? null,
|
|
91
|
+
remote: config?.git?.remote ?? null,
|
|
88
92
|
},
|
|
89
93
|
counts,
|
|
90
94
|
inProgress: inProgress.map((t) => ({
|
|
@@ -94,96 +98,111 @@ async function buildReport(cwd) {
|
|
|
94
98
|
})),
|
|
95
99
|
focus: focusBlock,
|
|
96
100
|
nextHint: hintToText(hint, cwd, focus),
|
|
101
|
+
nextLines: [...hintToLines(hint, cwd, focus)],
|
|
97
102
|
llm,
|
|
98
103
|
};
|
|
99
104
|
}
|
|
100
|
-
function
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
log.info(bold("
|
|
105
|
-
log.info(` ${dim("CLI")} v${report.vibeopsVersion}`);
|
|
105
|
+
function filledLabel(ok) {
|
|
106
|
+
return ok ? green("ok") : yellow("empty");
|
|
107
|
+
}
|
|
108
|
+
function printNow(report) {
|
|
109
|
+
log.info(bold(" NOW"));
|
|
106
110
|
if (!report.isVibeopsProject) {
|
|
107
|
-
log.info(`
|
|
108
|
-
log.info(bold("─".repeat(56)));
|
|
109
|
-
log.blank();
|
|
111
|
+
log.info(` ${dim("Project")} ${yellow("not a VibeOps project")}`);
|
|
110
112
|
return;
|
|
111
113
|
}
|
|
112
|
-
log.info(` ${dim("Project")} ${report.projectName ?? "(unnamed)"}`);
|
|
113
|
-
if (report.clients.length > 0) {
|
|
114
|
-
log.info(` ${dim("Clients")} ${report.clients.join(", ")}`);
|
|
115
|
-
}
|
|
116
|
-
if (report.counts) {
|
|
117
|
-
const c = report.counts;
|
|
118
|
-
log.info(` ${dim("Tasks")} ${c.total} total · ${cyan(String(c.in_progress))} in progress · ${c.shipped} shipped`);
|
|
119
|
-
}
|
|
120
|
-
if (report.inProgress.length > 1) {
|
|
121
|
-
log.blank();
|
|
122
|
-
log.info(bold(" In progress"));
|
|
123
|
-
for (const t of report.inProgress) {
|
|
124
|
-
log.info(` ${cyan(t.id)} — ${t.title}`);
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
log.blank();
|
|
128
114
|
if (report.focus) {
|
|
129
115
|
const f = report.focus;
|
|
130
|
-
log.info(`
|
|
131
|
-
log.info(`
|
|
132
|
-
log.info(`
|
|
133
|
-
if (f.
|
|
134
|
-
|
|
135
|
-
|
|
116
|
+
log.info(` ${dim("Focus")} ${cyan(f.id)} — ${f.title || dim("(no title)")}`);
|
|
117
|
+
log.info(` ${dim("Stage")} ${f.status}`);
|
|
118
|
+
log.info(` ${dim("Checklist")} Result ${filledLabel(f.resultFilled)} · Test Result ${filledLabel(f.testResultFilled)}`);
|
|
119
|
+
if (f.taskBranch) {
|
|
120
|
+
const parts = [];
|
|
121
|
+
if (f.onTaskBranch === true)
|
|
122
|
+
parts.push("on it");
|
|
123
|
+
else if (f.onTaskBranch === false)
|
|
124
|
+
parts.push("not checked out");
|
|
125
|
+
if (report.git.dirty === true)
|
|
126
|
+
parts.push("dirty");
|
|
127
|
+
else if (report.git.dirty === false)
|
|
128
|
+
parts.push("clean");
|
|
129
|
+
const suffix = parts.length > 0 ? ` (${parts.join(" · ")})` : "";
|
|
130
|
+
log.info(` ${dim("Branch")} ${f.taskBranch}${suffix}`);
|
|
131
|
+
}
|
|
132
|
+
else if (report.git.isRepo) {
|
|
133
|
+
log.info(` ${dim("Branch")} ${report.git.branch ?? "(detached)"}${report.git.dirty === true ? yellow(" · dirty") : ""}`);
|
|
134
|
+
}
|
|
135
|
+
if (f.needsSync) {
|
|
136
|
+
log.info(` ${dim("PR")} ${dim("(merged — sync pending)")}`);
|
|
137
|
+
}
|
|
138
|
+
else if (f.mergeRequestUrl) {
|
|
139
|
+
const tag = f.mergeRequestMerged ? dim(" · merged") : "";
|
|
140
|
+
log.info(` ${dim("PR")} ${f.mergeRequestUrl}${tag}`);
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
log.info(` ${dim("PR")} ${dim("(none)")}`);
|
|
144
|
+
}
|
|
136
145
|
}
|
|
137
146
|
else {
|
|
138
|
-
log.info(`
|
|
147
|
+
log.info(` ${dim("Focus")} ${dim("(none)")}`);
|
|
148
|
+
if (report.git.isRepo) {
|
|
149
|
+
log.info(` ${dim("Branch")} ${report.git.branch ?? "(detached)"}${report.git.dirty === true ? yellow(" · dirty") : ""}`);
|
|
150
|
+
}
|
|
139
151
|
}
|
|
152
|
+
}
|
|
153
|
+
function printNext(report) {
|
|
140
154
|
log.blank();
|
|
141
|
-
log.info(bold("
|
|
142
|
-
|
|
143
|
-
log.info(` ${
|
|
155
|
+
log.info(bold(" NEXT"));
|
|
156
|
+
for (const line of report.nextLines) {
|
|
157
|
+
log.info(` ${cyan("→")} ${line}`);
|
|
144
158
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
159
|
+
}
|
|
160
|
+
function printDetails(report) {
|
|
161
|
+
log.blank();
|
|
162
|
+
log.info(dim(" ──"));
|
|
163
|
+
if (!report.isVibeopsProject) {
|
|
164
|
+
log.info(` ${dim("CLI")} v${report.vibeopsVersion}`);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const clients = report.clients.length > 0 ? report.clients.join(", ") : dim("(none)");
|
|
168
|
+
log.info(` ${dim("Project")} ${report.projectName ?? "(unnamed)"} · clients ${clients}`);
|
|
169
|
+
if (report.counts) {
|
|
170
|
+
const c = report.counts;
|
|
171
|
+
log.info(` ${dim("Tasks")} ${c.total} total · ${cyan(String(c.in_progress))} in progress · ${c.shipped} shipped`);
|
|
172
|
+
}
|
|
173
|
+
if (report.inProgress.length > 1) {
|
|
174
|
+
log.info(` ${dim("Active")} ${report.inProgress.map((t) => t.id).join(", ")}`);
|
|
175
|
+
}
|
|
176
|
+
const hostBits = [];
|
|
177
|
+
if (report.git.host)
|
|
178
|
+
hostBits.push(report.git.host);
|
|
179
|
+
if (report.git.remote)
|
|
180
|
+
hostBits.push(report.git.remote);
|
|
181
|
+
if (report.git.policy)
|
|
182
|
+
hostBits.push(report.git.policy);
|
|
183
|
+
if (hostBits.length > 0) {
|
|
184
|
+
log.info(` ${dim("Git")} ${hostBits.join(" · ")}`);
|
|
165
185
|
}
|
|
166
186
|
if (report.llm) {
|
|
167
|
-
log.blank();
|
|
168
|
-
log.info(bold(" LLM"));
|
|
169
|
-
log.info(` ${dim("Preferred")} ${report.llm.preference}`);
|
|
170
187
|
const active = report.llm.active !== null
|
|
171
|
-
?
|
|
188
|
+
? `${report.llm.active} (ok)`
|
|
172
189
|
: report.llm.anyAvailable
|
|
173
|
-
?
|
|
174
|
-
:
|
|
175
|
-
log.info(`
|
|
176
|
-
for (const p of report.llm.providers) {
|
|
177
|
-
const summary = p.summary.length > 56 ? `${p.summary.slice(0, 53)}…` : p.summary;
|
|
178
|
-
log.info(` ${dim(p.id.padEnd(14))} ${p.ok ? green("ok") : dim("—")} ${dim(summary)}`);
|
|
179
|
-
}
|
|
180
|
-
if (!report.llm.anyAvailable) {
|
|
181
|
-
log.info(` ${dim("Setup")} ${cyan("vibeops llm connect")}`);
|
|
182
|
-
}
|
|
190
|
+
? "(preference not available)"
|
|
191
|
+
: "none";
|
|
192
|
+
log.info(` ${dim("LLM")} ${active} · prefer ${report.llm.preference}${report.llm.anyAvailable ? "" : ` · ${cyan("vibeops llm connect")}`}`);
|
|
183
193
|
}
|
|
194
|
+
}
|
|
195
|
+
function printHuman(report) {
|
|
196
|
+
log.blank();
|
|
197
|
+
log.info(bold("─".repeat(44)));
|
|
198
|
+
log.info(bold(" VibeOps · status"));
|
|
199
|
+
log.info(bold("─".repeat(44)));
|
|
200
|
+
log.blank();
|
|
201
|
+
printNow(report);
|
|
202
|
+
printNext(report);
|
|
203
|
+
printDetails(report);
|
|
184
204
|
log.blank();
|
|
185
|
-
log.info(
|
|
186
|
-
log.info(bold("─".repeat(56)));
|
|
205
|
+
log.info(bold("─".repeat(44)));
|
|
187
206
|
log.blank();
|
|
188
207
|
}
|
|
189
208
|
export async function statusCommand(opts = {}) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { GitConfigError, requireGitConfig } from "../lib/git-config.js";
|
|
3
|
+
import { formatHostCliHint, formatHostCliMissingMessage } from "../lib/git-host-cli.js";
|
|
3
4
|
import { detectGitHost, mergeRequestLabel } from "../lib/git-host.js";
|
|
4
5
|
import { gitRemoteUrl } from "../lib/git.js";
|
|
5
6
|
import { bold, cyan, dim, log } from "../lib/logger.js";
|
|
@@ -55,7 +56,7 @@ export async function taskMergeCommand(taskRef, options = {}) {
|
|
|
55
56
|
? null
|
|
56
57
|
: await resolveOpenTaskMergeRequest(cwd, target.taskFile);
|
|
57
58
|
if (!dryRun && resolvedMr === null) {
|
|
58
|
-
log.error(`No open MR/PR for ${ctx.taskBranch} → ${ctx.baseBranch}. Run task ship
|
|
59
|
+
log.error(`No open MR/PR for ${ctx.taskBranch} → ${ctx.baseBranch}. Run task ship first.`);
|
|
59
60
|
process.exitCode = 1;
|
|
60
61
|
return;
|
|
61
62
|
}
|
|
@@ -96,8 +97,11 @@ export async function taskMergeCommand(taskRef, options = {}) {
|
|
|
96
97
|
if (state === "open") {
|
|
97
98
|
const cliOk = await probeMergeRequestCli(host);
|
|
98
99
|
if (!cliOk) {
|
|
99
|
-
|
|
100
|
-
|
|
100
|
+
log.error(formatHostCliMissingMessage(host, `merge the ${label}`));
|
|
101
|
+
for (const line of formatHostCliHint(host)) {
|
|
102
|
+
log.info(dim(` ${line}`));
|
|
103
|
+
}
|
|
104
|
+
log.info(dim("Or merge in the host UI, then task sync."));
|
|
101
105
|
process.exitCode = 1;
|
|
102
106
|
return;
|
|
103
107
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { GitConfigError, requireGitConfig } from "../lib/git-config.js";
|
|
3
|
+
import { formatHostCliHint, formatHostCliMissingMessage } from "../lib/git-host-cli.js";
|
|
3
4
|
import { detectGitHost, mergeRequestLabel } from "../lib/git-host.js";
|
|
4
5
|
import { gitRemoteUrl } from "../lib/git.js";
|
|
5
6
|
import { bold, dim, log } from "../lib/logger.js";
|
|
@@ -57,8 +58,10 @@ export async function taskReleaseCommand(options = {}) {
|
|
|
57
58
|
}
|
|
58
59
|
const cliOk = await probeMergeRequestCli(host);
|
|
59
60
|
if (!cliOk) {
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
log.error(formatHostCliMissingMessage(host, `create the release ${label}`));
|
|
62
|
+
for (const line of formatHostCliHint(host)) {
|
|
63
|
+
log.info(dim(` ${line}`));
|
|
64
|
+
}
|
|
62
65
|
process.exitCode = 1;
|
|
63
66
|
return;
|
|
64
67
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const execFileAsync = promisify(execFile);
|
|
4
|
+
/** Run a CLI with inherited stdio (e.g. `gh auth login`). */
|
|
5
|
+
export function runInteractiveCli(bin, args) {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
const child = spawn(bin, [...args], { stdio: "inherit" });
|
|
8
|
+
child.on("error", reject);
|
|
9
|
+
child.on("close", (code) => resolve(code ?? 1));
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
export function hostCliTool(host) {
|
|
13
|
+
return host === "gitlab" ? "glab" : "gh";
|
|
14
|
+
}
|
|
15
|
+
export async function commandExists(bin) {
|
|
16
|
+
try {
|
|
17
|
+
await execFileAsync(bin, ["--version"], { maxBuffer: 1024 });
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export async function probeGhAuth() {
|
|
25
|
+
try {
|
|
26
|
+
await execFileAsync("gh", ["auth", "status"], { maxBuffer: 1024 * 1024 });
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export async function probeGlabAuth() {
|
|
34
|
+
try {
|
|
35
|
+
await execFileAsync("glab", ["auth", "status"], { maxBuffer: 1024 * 1024 });
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export async function probeHostCliAuth(host) {
|
|
43
|
+
const tool = hostCliTool(host);
|
|
44
|
+
if (!(await commandExists(tool)))
|
|
45
|
+
return "missing";
|
|
46
|
+
const ok = host === "gitlab" ? await probeGlabAuth() : await probeGhAuth();
|
|
47
|
+
return ok ? "ok" : "unauthenticated";
|
|
48
|
+
}
|
|
49
|
+
/** Install / login lines shared by init and ship/merge. */
|
|
50
|
+
export function formatHostCliHint(host) {
|
|
51
|
+
if (host === "gitlab") {
|
|
52
|
+
return [
|
|
53
|
+
`Install glab: https://gitlab.com/gitlab-org/cli#installation`,
|
|
54
|
+
` brew install glab`,
|
|
55
|
+
`Then: glab auth login`,
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
return [
|
|
59
|
+
`Install gh: https://cli.github.com/`,
|
|
60
|
+
` brew install gh`,
|
|
61
|
+
`Then: gh auth login`,
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
export function formatHostCliMissingMessage(host, purpose) {
|
|
65
|
+
const tool = hostCliTool(host);
|
|
66
|
+
return `${tool} CLI is required to ${purpose}.`;
|
|
67
|
+
}
|
|
68
|
+
export async function probeBrew() {
|
|
69
|
+
return commandExists("brew");
|
|
70
|
+
}
|
|
71
|
+
export async function brewInstall(pkg) {
|
|
72
|
+
await execFileAsync("brew", ["install", pkg], {
|
|
73
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/** Parse `owner/repo`, HTTPS, or SSH GitHub/GitLab URLs. */
|
|
77
|
+
export function parseOwnerRepo(input) {
|
|
78
|
+
const raw = input.trim();
|
|
79
|
+
if (raw.length === 0)
|
|
80
|
+
return null;
|
|
81
|
+
const slug = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?$/;
|
|
82
|
+
const fromSlug = slug.exec(raw);
|
|
83
|
+
if (fromSlug && !raw.includes(":") && !raw.includes("@")) {
|
|
84
|
+
return { owner: fromSlug[1], repo: fromSlug[2] };
|
|
85
|
+
}
|
|
86
|
+
const https = /^https?:\/\/(?:www\.)?(?:github\.com|gitlab\.com)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/i.exec(raw);
|
|
87
|
+
if (https) {
|
|
88
|
+
return { owner: https[1], repo: https[2].replace(/\.git$/i, "") };
|
|
89
|
+
}
|
|
90
|
+
const ssh = /^(?:git@|ssh:\/\/git@)(?:github\.com|gitlab\.com)[:/]([^/]+)\/([^/]+?)(?:\.git)?$/i.exec(raw);
|
|
91
|
+
if (ssh) {
|
|
92
|
+
return { owner: ssh[1], repo: ssh[2].replace(/\.git$/i, "") };
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
export function remoteUrlForHost(host, owner, repo) {
|
|
97
|
+
if (host === "gitlab") {
|
|
98
|
+
return `https://gitlab.com/${owner}/${repo}.git`;
|
|
99
|
+
}
|
|
100
|
+
return `https://github.com/${owner}/${repo}.git`;
|
|
101
|
+
}
|
|
102
|
+
export async function repoExistsOnHost(host, owner, repo) {
|
|
103
|
+
const slug = `${owner}/${repo}`;
|
|
104
|
+
try {
|
|
105
|
+
if (host === "gitlab") {
|
|
106
|
+
await execFileAsync("glab", ["repo", "view", slug], { maxBuffer: 1024 * 1024 });
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
await execFileAsync("gh", ["repo", "view", slug], { maxBuffer: 1024 * 1024 });
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
package/dist/lib/git-remote.js
CHANGED
|
@@ -1,32 +1,14 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
1
2
|
import { execFile } from "node:child_process";
|
|
2
3
|
import { promisify } from "node:util";
|
|
3
|
-
import {
|
|
4
|
+
import { brewInstall, formatHostCliHint, formatHostCliMissingMessage, hostCliTool, parseOwnerRepo, probeBrew, probeHostCliAuth, remoteUrlForHost, repoExistsOnHost, runInteractiveCli, } from "./git-host-cli.js";
|
|
4
5
|
import { detectGitHost } from "./git-host.js";
|
|
5
6
|
import { gitRemoteUrl, runGit } from "./git.js";
|
|
6
|
-
import {
|
|
7
|
+
import { askInput, askSelect, askYesNo } from "./inquirer-helpers.js";
|
|
8
|
+
import { dim, log, yellow } from "./logger.js";
|
|
7
9
|
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
10
|
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 });
|
|
11
|
+
const { stdout } = await execFileAsync("gh", ["repo", "create", name, "--source=.", "--remote=origin", "--push", "--private"], { cwd, maxBuffer: 8 * 1024 * 1024 });
|
|
30
12
|
const match = stdout.match(/https:\/\/github\.com\/[^\s]+/);
|
|
31
13
|
if (match)
|
|
32
14
|
return match[0].replace(/\.git$/, "") + ".git";
|
|
@@ -42,8 +24,135 @@ async function createGitlabRepo(cwd, name, defaultBranch) {
|
|
|
42
24
|
return url;
|
|
43
25
|
throw new Error("glab repo create finished but origin URL could not be read.");
|
|
44
26
|
}
|
|
27
|
+
async function addRemoteUrl(cwd, remote, url) {
|
|
28
|
+
const host = detectGitHost(url);
|
|
29
|
+
if (!host) {
|
|
30
|
+
throw new Error("Only GitHub and GitLab HTTPS/SSH URLs are supported.");
|
|
31
|
+
}
|
|
32
|
+
await runGit(cwd, ["remote", "add", remote, url.trim()]);
|
|
33
|
+
return { remote, url: url.trim(), host };
|
|
34
|
+
}
|
|
35
|
+
async function resolveConnectUrl(host, input) {
|
|
36
|
+
const trimmed = input.trim();
|
|
37
|
+
const parsed = parseOwnerRepo(trimmed);
|
|
38
|
+
if (parsed) {
|
|
39
|
+
if (trimmed.startsWith("http") || trimmed.startsWith("git@") || trimmed.startsWith("ssh://")) {
|
|
40
|
+
return trimmed.endsWith(".git") ? trimmed : `${trimmed.replace(/\/$/, "")}.git`;
|
|
41
|
+
}
|
|
42
|
+
return remoteUrlForHost(host, parsed.owner, parsed.repo);
|
|
43
|
+
}
|
|
44
|
+
if (detectGitHost(trimmed)) {
|
|
45
|
+
return trimmed.endsWith(".git") ? trimmed : `${trimmed.replace(/\/$/, "")}.git`;
|
|
46
|
+
}
|
|
47
|
+
throw new Error(`Could not parse remote: ${trimmed}`);
|
|
48
|
+
}
|
|
49
|
+
async function repairMissingCli(host) {
|
|
50
|
+
const tool = hostCliTool(host);
|
|
51
|
+
const status = await probeHostCliAuth(host);
|
|
52
|
+
log.warn(status === "missing"
|
|
53
|
+
? formatHostCliMissingMessage(host, "create a remote repository")
|
|
54
|
+
: `${tool} is installed but not logged in.`);
|
|
55
|
+
for (const line of formatHostCliHint(host)) {
|
|
56
|
+
log.info(dim(` ${line}`));
|
|
57
|
+
}
|
|
58
|
+
const brewOk = status === "missing" ? await probeBrew() : false;
|
|
59
|
+
const choices = [
|
|
60
|
+
"I installed / logged in — retry",
|
|
61
|
+
...(brewOk ? [`Run brew install ${tool}`] : []),
|
|
62
|
+
"Enter remote URL manually",
|
|
63
|
+
"Skip remote for now",
|
|
64
|
+
];
|
|
65
|
+
// Loop so brew install / retry can re-prompt.
|
|
66
|
+
for (;;) {
|
|
67
|
+
const pick = await askSelect({
|
|
68
|
+
message: "How do you want to continue?",
|
|
69
|
+
nonInteractive: false,
|
|
70
|
+
choices,
|
|
71
|
+
});
|
|
72
|
+
if (pick === "I installed / logged in — retry") {
|
|
73
|
+
const again = await probeHostCliAuth(host);
|
|
74
|
+
if (again === "ok")
|
|
75
|
+
return "retry";
|
|
76
|
+
log.warn(again === "missing"
|
|
77
|
+
? `${tool} still not found on PATH.`
|
|
78
|
+
: `${tool} still not authenticated. Run \`${tool} auth login\`.`);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (pick.startsWith("Run brew install")) {
|
|
82
|
+
log.info(dim(`Running: brew install ${tool}`));
|
|
83
|
+
try {
|
|
84
|
+
await brewInstall(tool);
|
|
85
|
+
log.ok(`Installed ${tool}`);
|
|
86
|
+
log.info(dim(`Next: ${tool} auth login`));
|
|
87
|
+
const loginNow = await askYesNo({
|
|
88
|
+
message: `Run \`${tool} auth login\` now?`,
|
|
89
|
+
nonInteractive: false,
|
|
90
|
+
defaultValue: true,
|
|
91
|
+
});
|
|
92
|
+
if (loginNow) {
|
|
93
|
+
try {
|
|
94
|
+
await runInteractiveCli(tool, ["auth", "login"]);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
/* user may cancel */
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
103
|
+
log.warn(`brew install failed: ${msg}`);
|
|
104
|
+
}
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (pick === "Enter remote URL manually")
|
|
108
|
+
return "manual";
|
|
109
|
+
return "skip";
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async function connectExisting(cwd, remote, host) {
|
|
113
|
+
const input = await askInput({
|
|
114
|
+
message: "Repository URL or owner/repo",
|
|
115
|
+
nonInteractive: false,
|
|
116
|
+
required: true,
|
|
117
|
+
});
|
|
118
|
+
const url = await resolveConnectUrl(host, input);
|
|
119
|
+
const parsed = parseOwnerRepo(url) ?? parseOwnerRepo(input);
|
|
120
|
+
if (parsed && (await probeHostCliAuth(host)) === "ok") {
|
|
121
|
+
const exists = await repoExistsOnHost(host, parsed.owner, parsed.repo);
|
|
122
|
+
if (!exists) {
|
|
123
|
+
log.warn(yellow(`Could not verify ${parsed.owner}/${parsed.repo} on ${host} (missing or private). Continuing with remote add.`));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return addRemoteUrl(cwd, remote, url);
|
|
127
|
+
}
|
|
128
|
+
async function createOnHost(cwd, remote, host, defaultBranch) {
|
|
129
|
+
const folder = basename(cwd);
|
|
130
|
+
const label = host === "gitlab" ? "GitLab project path (group/project)" : "GitHub repository (owner/repo or name)";
|
|
131
|
+
const name = await askInput({
|
|
132
|
+
message: label,
|
|
133
|
+
nonInteractive: false,
|
|
134
|
+
required: true,
|
|
135
|
+
default: folder,
|
|
136
|
+
});
|
|
137
|
+
log.info(dim(`Creating ${host} repository via ${hostCliTool(host)}…`));
|
|
138
|
+
if (host === "gitlab") {
|
|
139
|
+
const url = await createGitlabRepo(cwd, name, defaultBranch);
|
|
140
|
+
// glab may name remote differently; ensure our remote name.
|
|
141
|
+
const current = await gitRemoteUrl(cwd, remote);
|
|
142
|
+
if (!current) {
|
|
143
|
+
const origin = await gitRemoteUrl(cwd, "origin");
|
|
144
|
+
if (origin && remote !== "origin") {
|
|
145
|
+
await runGit(cwd, ["remote", "rename", "origin", remote]);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { remote, url, host };
|
|
149
|
+
}
|
|
150
|
+
const url = await createGithubRepo(cwd, name);
|
|
151
|
+
return { remote, url, host };
|
|
152
|
+
}
|
|
45
153
|
/**
|
|
46
|
-
* Ensures `origin` (or named remote) exists.
|
|
154
|
+
* Ensures `origin` (or named remote) exists.
|
|
155
|
+
* Interactive: ask host → create/connect; soft-gate when CLI missing.
|
|
47
156
|
*/
|
|
48
157
|
export async function ensureOriginRemote(opts) {
|
|
49
158
|
const remote = opts.remoteName ?? "origin";
|
|
@@ -59,8 +168,9 @@ export async function ensureOriginRemote(opts) {
|
|
|
59
168
|
return { remote, url: existing, host };
|
|
60
169
|
}
|
|
61
170
|
if (opts.dryRun) {
|
|
62
|
-
|
|
63
|
-
|
|
171
|
+
const host = opts.preferredHost ?? "github";
|
|
172
|
+
log.info(dim(`would configure git remote ${remote} (${host})`));
|
|
173
|
+
return { remote, url: "(dry-run)", host };
|
|
64
174
|
}
|
|
65
175
|
if (opts.allowMissing) {
|
|
66
176
|
return null;
|
|
@@ -69,40 +179,48 @@ export async function ensureOriginRemote(opts) {
|
|
|
69
179
|
throw new Error(`Git remote "${remote}" is not configured. Add it with: git remote add ${remote} <url>`);
|
|
70
180
|
}
|
|
71
181
|
log.blank();
|
|
72
|
-
log.info("Remote
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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" };
|
|
182
|
+
log.info("Remote setup — used later for pull requests / merge requests.");
|
|
183
|
+
const hostPick = await askSelect({
|
|
184
|
+
message: "Where will this project's remote live?",
|
|
185
|
+
nonInteractive: false,
|
|
186
|
+
choices: ["GitHub", "GitLab", "Skip for now (local only)"],
|
|
187
|
+
default: opts.preferredHost === "gitlab"
|
|
188
|
+
? "GitLab"
|
|
189
|
+
: opts.preferredHost === "github"
|
|
190
|
+
? "GitHub"
|
|
191
|
+
: undefined,
|
|
192
|
+
});
|
|
193
|
+
if (hostPick.startsWith("Skip")) {
|
|
194
|
+
log.warn("No git remote configured. task ship push/MR will fail until origin exists.");
|
|
195
|
+
log.info(dim(` Later: git remote add ${remote} <url>`));
|
|
196
|
+
return null;
|
|
97
197
|
}
|
|
98
|
-
const
|
|
99
|
-
|
|
198
|
+
const host = hostPick === "GitLab" ? "gitlab" : "github";
|
|
199
|
+
const mode = await askSelect({
|
|
200
|
+
message: "Set up the remote",
|
|
100
201
|
nonInteractive: false,
|
|
101
|
-
|
|
202
|
+
choices: ["Create a new repository", "Connect an existing repository"],
|
|
102
203
|
});
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
204
|
+
if (mode.startsWith("Connect")) {
|
|
205
|
+
return connectExisting(opts.cwd, remote, host);
|
|
206
|
+
}
|
|
207
|
+
// Create path — requires authenticated CLI (soft gate).
|
|
208
|
+
for (;;) {
|
|
209
|
+
const auth = await probeHostCliAuth(host);
|
|
210
|
+
if (auth === "ok") {
|
|
211
|
+
const defaultBranch = typeof opts.defaultBranch === "string" && opts.defaultBranch.length > 0
|
|
212
|
+
? opts.defaultBranch
|
|
213
|
+
: "main";
|
|
214
|
+
return createOnHost(opts.cwd, remote, host, defaultBranch);
|
|
215
|
+
}
|
|
216
|
+
const action = await repairMissingCli(host);
|
|
217
|
+
if (action === "retry")
|
|
218
|
+
continue;
|
|
219
|
+
if (action === "manual") {
|
|
220
|
+
return connectExisting(opts.cwd, remote, host);
|
|
221
|
+
}
|
|
222
|
+
log.warn("No git remote configured. task ship push/MR will fail until origin exists.");
|
|
223
|
+
log.info(dim(` Later: git remote add ${remote} <url>`));
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
108
226
|
}
|
package/dist/lib/task-context.js
CHANGED
|
@@ -49,7 +49,7 @@ export function computeNextHint(input) {
|
|
|
49
49
|
if (input.hasMergeRequest && !input.mergeRequestMerged)
|
|
50
50
|
return "task-merge";
|
|
51
51
|
if (input.hasLocalChanges)
|
|
52
|
-
return "task-
|
|
52
|
+
return "task-ship-followup";
|
|
53
53
|
return "task-add";
|
|
54
54
|
}
|
|
55
55
|
if (!input.resultFilled || !input.testFilled) {
|
|
@@ -57,36 +57,48 @@ export function computeNextHint(input) {
|
|
|
57
57
|
}
|
|
58
58
|
return input.hasMergeRequest ? "task-merge" : "task-ship";
|
|
59
59
|
}
|
|
60
|
-
|
|
60
|
+
/** Multi-line NEXT block lines (without the leading arrow prefix). */
|
|
61
|
+
export function hintToLines(hint, cwd, task) {
|
|
61
62
|
switch (hint) {
|
|
62
63
|
case "init":
|
|
63
|
-
return "
|
|
64
|
+
return ["vibeops init"];
|
|
64
65
|
case "task-add":
|
|
65
|
-
return "
|
|
66
|
+
return ["vibeops task add"];
|
|
66
67
|
case "task-ship":
|
|
67
|
-
return task ? `
|
|
68
|
-
case "task-
|
|
68
|
+
return [task ? `vibeops task ship ${task.id}` : "vibeops task ship"];
|
|
69
|
+
case "task-ship-followup":
|
|
69
70
|
return task
|
|
70
|
-
?
|
|
71
|
-
|
|
71
|
+
? [
|
|
72
|
+
`Follow up on ${task.id} (after merge: confirm or --new-cycle)`,
|
|
73
|
+
`vibeops task ship ${task.id}`,
|
|
74
|
+
]
|
|
75
|
+
: ["vibeops task ship --new-cycle"];
|
|
72
76
|
case "task-merge":
|
|
73
77
|
return task
|
|
74
|
-
? `
|
|
75
|
-
: "
|
|
78
|
+
? [`vibeops task merge ${task.id}`, "(or merge in the host UI)"]
|
|
79
|
+
: ["vibeops task merge"];
|
|
76
80
|
case "task-sync":
|
|
77
81
|
return task
|
|
78
|
-
? `
|
|
79
|
-
: "
|
|
82
|
+
? [`vibeops task sync ${task.id}`, "then: vibeops task add"]
|
|
83
|
+
: ["vibeops task sync"];
|
|
80
84
|
case "cursor-plan":
|
|
81
85
|
return task
|
|
82
|
-
?
|
|
83
|
-
|
|
86
|
+
? [
|
|
87
|
+
`Cursor Ask: @${relPath(cwd, task.filePath)} — refine Scope / AC`,
|
|
88
|
+
]
|
|
89
|
+
: ["Open the TASK file in Cursor Ask"];
|
|
84
90
|
case "cursor-implement":
|
|
85
91
|
return task
|
|
86
|
-
?
|
|
87
|
-
|
|
92
|
+
? [
|
|
93
|
+
`Cursor Agent: @${relPath(cwd, task.filePath)} — implement`,
|
|
94
|
+
`then: vibeops task ship ${task.id}`,
|
|
95
|
+
]
|
|
96
|
+
: ["Implement per the TASK file, then vibeops task ship"];
|
|
88
97
|
}
|
|
89
98
|
}
|
|
99
|
+
export function hintToText(hint, cwd, task) {
|
|
100
|
+
return hintToLines(hint, cwd, task).join(" · ");
|
|
101
|
+
}
|
|
90
102
|
export async function listInProgressTasks(paths) {
|
|
91
103
|
return (await loadActionableTasks(paths.docsTasks)).filter((t) => t.status === "in_progress");
|
|
92
104
|
}
|
package/dist/lib/task-push-pr.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { formatHostCliHint, formatHostCliMissingMessage } from "./git-host-cli.js";
|
|
1
2
|
import { readText } from "./filesystem.js";
|
|
2
3
|
import { readConfig } from "./config.js";
|
|
3
4
|
import { detectGitHost, mergeRequestLabel } from "./git-host.js";
|
|
@@ -94,8 +95,11 @@ export async function finishTaskWithPullRequest(opts) {
|
|
|
94
95
|
}
|
|
95
96
|
const cliOk = await probeMergeRequestCli(gitCfg.host);
|
|
96
97
|
if (!cliOk) {
|
|
97
|
-
|
|
98
|
-
|
|
98
|
+
log.warn(formatHostCliMissingMessage(gitCfg.host, `create the ${label}`));
|
|
99
|
+
for (const line of formatHostCliHint(gitCfg.host)) {
|
|
100
|
+
log.info(dim(` ${line}`));
|
|
101
|
+
}
|
|
102
|
+
log.info(dim(`Create the ${label} manually: ${gitCtx.taskBranch} → ${gitCtx.baseBranch}`));
|
|
99
103
|
return { ok: true, pushed: true };
|
|
100
104
|
}
|
|
101
105
|
const config = await readConfig(opts.cwd);
|
package/dist/lib/task-reship.js
CHANGED
|
@@ -43,7 +43,7 @@ export function reshipNotFoundMessage(taskRef) {
|
|
|
43
43
|
if (taskRef?.trim()) {
|
|
44
44
|
return `Shipped TASK not found: ${normalizeTaskRef(taskRef)}. Check docs/tasks/.`;
|
|
45
45
|
}
|
|
46
|
-
return "No Shipped TASK found. Pass `vibeops task
|
|
46
|
+
return "No Shipped TASK found. Pass `vibeops task ship TASK-NNN` (use --new-cycle after merge).";
|
|
47
47
|
}
|
|
48
48
|
async function switchToTaskBranch(cwd, taskBranch) {
|
|
49
49
|
try {
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodtek/vibeops",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"description": "Minimal CLI rails for Cursor-based TASK-driven vibe coding.",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"packageManager": "pnpm@9.15.9",
|
|
6
7
|
"author": "VibeOps contributors",
|
|
7
8
|
"license": "MIT",
|
|
8
9
|
"repository": {
|
|
@@ -29,6 +30,17 @@
|
|
|
29
30
|
"engines": {
|
|
30
31
|
"node": ">=20"
|
|
31
32
|
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"dev": "tsx src/cli.ts",
|
|
35
|
+
"build": "tsc -p tsconfig.json",
|
|
36
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
37
|
+
"start": "node dist/cli.js",
|
|
38
|
+
"test": "pnpm build && node --test \"tests/*.test.mjs\"",
|
|
39
|
+
"smoke": "pnpm typecheck && pnpm build && node --test \"tests/*.test.mjs\" && node scripts/smoke.mjs",
|
|
40
|
+
"clean": "rm -rf dist",
|
|
41
|
+
"prepack": "pnpm clean && pnpm build",
|
|
42
|
+
"publish:dry": "pnpm publish --dry-run --no-git-checks"
|
|
43
|
+
},
|
|
32
44
|
"keywords": [
|
|
33
45
|
"cursor",
|
|
34
46
|
"ai",
|
|
@@ -47,15 +59,5 @@
|
|
|
47
59
|
"@types/node": "^20.14.0",
|
|
48
60
|
"tsx": "^4.19.0",
|
|
49
61
|
"typescript": "^5.5.4"
|
|
50
|
-
},
|
|
51
|
-
"scripts": {
|
|
52
|
-
"dev": "tsx src/cli.ts",
|
|
53
|
-
"build": "tsc -p tsconfig.json",
|
|
54
|
-
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
55
|
-
"start": "node dist/cli.js",
|
|
56
|
-
"test": "pnpm build && node --test \"tests/*.test.mjs\"",
|
|
57
|
-
"smoke": "pnpm typecheck && pnpm build && node --test \"tests/*.test.mjs\" && node scripts/smoke.mjs",
|
|
58
|
-
"clean": "rm -rf dist",
|
|
59
|
-
"publish:dry": "pnpm publish --dry-run --no-git-checks"
|
|
60
62
|
}
|
|
61
|
-
}
|
|
63
|
+
}
|
package/templates/core/AGENTS.md
CHANGED
|
@@ -40,5 +40,5 @@ vibeops llm connect # optional — task add / ship
|
|
|
40
40
|
|
|
41
41
|
- One **In Progress** TASK at a time; **Shipped** does not block `task add`.
|
|
42
42
|
- Fill **Result** and **Test Result** before `vibeops task ship` (warned, not blocked, when updating an open PR).
|
|
43
|
-
- `ship` is state-aware: re-run it to update an open PR; after merge it needs `--new-cycle` (or a confirm) to start a new PR cycle.
|
|
43
|
+
- `ship` is state-aware: re-run it to update an open PR; after merge it needs `--new-cycle` (or a confirm) to start a new PR cycle.
|
|
44
44
|
- Agents do not run `task merge` or `task sync` unless the human asks.
|
|
@@ -5,5 +5,5 @@
|
|
|
5
5
|
## D-001 · VibeOps workflow
|
|
6
6
|
|
|
7
7
|
- **Date:** {{CREATED_AT}}
|
|
8
|
-
- **Decision:** TASK-driven development with `vibeops task add` / `task ship` / `
|
|
8
|
+
- **Decision:** TASK-driven development with `vibeops task add` / `task ship` / `merge` / `sync` and Cursor for plan/build. TASK status: **In Progress** → **Shipped** only.
|
|
9
9
|
- **Why:** Git `docs/tasks` beats chat as source of truth.
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { dim, log, yellow } from "../lib/logger.js";
|
|
2
|
-
import { taskShipCommand } from "./task-ship.js";
|
|
3
|
-
/**
|
|
4
|
-
* @deprecated `reship` is now `vibeops task ship --new-cycle`.
|
|
5
|
-
* Kept as a thin alias for backward compatibility.
|
|
6
|
-
*/
|
|
7
|
-
export async function taskReshipCommand(taskRef, options = {}) {
|
|
8
|
-
log.warn(`${yellow("reship is now `ship` (deprecated alias)")} — delegating to \`vibeops task ship --new-cycle\`.`);
|
|
9
|
-
log.info(dim("Run `vibeops task ship` directly; it detects first submit / open-PR update / new PR cycle."));
|
|
10
|
-
log.blank();
|
|
11
|
-
await taskShipCommand(taskRef, {
|
|
12
|
-
dryRun: options.dryRun,
|
|
13
|
-
cwd: options.cwd,
|
|
14
|
-
noPr: options.noPr,
|
|
15
|
-
newCycle: true,
|
|
16
|
-
noIntegrate: options.noIntegrate,
|
|
17
|
-
recreateBranch: options.recreateBranch,
|
|
18
|
-
skipLlm: options.skipLlm,
|
|
19
|
-
allowOpenMr: options.allowOpenMr,
|
|
20
|
-
});
|
|
21
|
-
}
|