otto-git-cli 4.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +178 -0
- package/index.js +5 -0
- package/package.json +37 -0
- package/src/commands/branch.js +106 -0
- package/src/commands/build.js +39 -0
- package/src/commands/index.js +8 -0
- package/src/commands/release.js +165 -0
- package/src/commands/settings.js +65 -0
- package/src/commands/stash.js +73 -0
- package/src/commands/sync.js +49 -0
- package/src/commands/undo.js +77 -0
- package/src/commands/updates.js +57 -0
- package/src/config.js +46 -0
- package/src/git/index.js +120 -0
- package/src/index.js +117 -0
- package/src/services/ai.js +39 -0
- package/src/services/index.js +2 -0
- package/src/services/sheets.js +18 -0
- package/src/ui/index.js +71 -0
- package/src/utils/index.js +2 -0
- package/src/utils/setup.js +59 -0
- package/src/utils/shell.js +17 -0
- package/src/utils/text.js +18 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { select, text, isCancel } from "@clack/prompts";
|
|
2
|
+
import { OPENAI_API_KEY, SHEET_WEBHOOK_URL, updateConfig } from "../config.js";
|
|
3
|
+
import pc from "picocolors";
|
|
4
|
+
|
|
5
|
+
export async function flowSettings() {
|
|
6
|
+
while (true) {
|
|
7
|
+
const maskKey = (key) =>
|
|
8
|
+
key ? `${key.substring(0, 3)}...${key.substring(key.length - 4)}` : "Not Set";
|
|
9
|
+
const maskUrl = (url) =>
|
|
10
|
+
url ? `${url.substring(0, 20)}...` : "Not Set";
|
|
11
|
+
|
|
12
|
+
const selection = await select({
|
|
13
|
+
message: "⚙️ Settings",
|
|
14
|
+
options: [
|
|
15
|
+
{
|
|
16
|
+
value: "openai",
|
|
17
|
+
label: "🤖 OpenAI API Key",
|
|
18
|
+
hint: maskKey(OPENAI_API_KEY),
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
value: "sheet",
|
|
22
|
+
label: "📊 Google Sheets URL",
|
|
23
|
+
hint: maskUrl(SHEET_WEBHOOK_URL),
|
|
24
|
+
},
|
|
25
|
+
{ value: "back", label: "🔙 Back" },
|
|
26
|
+
],
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (isCancel(selection) || selection === "back") return;
|
|
30
|
+
|
|
31
|
+
if (selection === "openai") {
|
|
32
|
+
const newKey = await text({
|
|
33
|
+
message: "Enter new OpenAI API Key:",
|
|
34
|
+
initialValue: OPENAI_API_KEY || "",
|
|
35
|
+
placeholder: "sk-...",
|
|
36
|
+
validate: (value) => {
|
|
37
|
+
if (!value) return "API Key is required.";
|
|
38
|
+
if (!value.startsWith("sk-")) return "Key usually starts with sk-";
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
if (!isCancel(newKey)) {
|
|
43
|
+
updateConfig("OPENAI_API_KEY", newKey);
|
|
44
|
+
console.log(pc.green("✔ OpenAI API Key updated."));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (selection === "sheet") {
|
|
49
|
+
const newUrl = await text({
|
|
50
|
+
message: "Enter Google Sheet Webhook URL:",
|
|
51
|
+
initialValue: SHEET_WEBHOOK_URL || "",
|
|
52
|
+
placeholder: "https://script.google.com/...",
|
|
53
|
+
validate: (val) => {
|
|
54
|
+
if (!val) return "URL is required.";
|
|
55
|
+
if (!val.startsWith("http")) return "Invalid URL.";
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (!isCancel(newUrl)) {
|
|
60
|
+
updateConfig("GOOGLE_SHEET_WEBHOOK_URL", newUrl);
|
|
61
|
+
console.log(pc.green("✔ Google Sheet URL updated."));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { spinner, select, text, isCancel, note } from "@clack/prompts";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import { git } from "../git/index.js";
|
|
4
|
+
import { sh } from "../utils/shell.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Stash manager flow - save and pop stashes
|
|
8
|
+
*/
|
|
9
|
+
export async function flowStash() {
|
|
10
|
+
if (!git.isRepo()) {
|
|
11
|
+
note("Not a git repository.", "Error");
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const action = await select({
|
|
16
|
+
message: "Stash Manager",
|
|
17
|
+
options: [
|
|
18
|
+
{ value: "save", label: "💾 Save", hint: "Stash current changes" },
|
|
19
|
+
{ value: "pop", label: "🥡 Pop", hint: "Apply saved stash" },
|
|
20
|
+
],
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
if (isCancel(action)) return;
|
|
24
|
+
|
|
25
|
+
if (action === "save") {
|
|
26
|
+
try {
|
|
27
|
+
const msg = await text({
|
|
28
|
+
message: "Stash Message (Optional)",
|
|
29
|
+
placeholder: "WIP: Refactoring...",
|
|
30
|
+
});
|
|
31
|
+
if (isCancel(msg)) return;
|
|
32
|
+
|
|
33
|
+
const s = spinner();
|
|
34
|
+
s.start(pc.dim("Saving stash..."));
|
|
35
|
+
git.stashSave(msg || "Otto Stash");
|
|
36
|
+
s.stop(pc.green("✔ Stashed successfully"));
|
|
37
|
+
} catch (e) {
|
|
38
|
+
note(e.message, "⚠ Info");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (action === "pop") {
|
|
43
|
+
const stashes = git.stashList();
|
|
44
|
+
if (stashes.length === 0) {
|
|
45
|
+
note("No stashes found.", "ℹ Empty");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const target = await select({
|
|
50
|
+
message: "Select Stash to Pop",
|
|
51
|
+
options: stashes.map((s) => ({
|
|
52
|
+
value: s.ref,
|
|
53
|
+
label: s.msg,
|
|
54
|
+
hint: s.ref,
|
|
55
|
+
})),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (isCancel(target)) return;
|
|
59
|
+
|
|
60
|
+
const s = spinner();
|
|
61
|
+
s.start(pc.dim(`Popping ${target}...`));
|
|
62
|
+
try {
|
|
63
|
+
sh(`git stash pop ${target}`);
|
|
64
|
+
s.stop(pc.green("✔ Popped successfully"));
|
|
65
|
+
} catch (e) {
|
|
66
|
+
s.stop(pc.red("✖ Pop resulted in conflicts"));
|
|
67
|
+
note(
|
|
68
|
+
"Changes are applied but there are merge conflicts. Resolve them manually.",
|
|
69
|
+
"⚠ Conflict"
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { spinner, note } from "@clack/prompts";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import { git } from "../git/index.js";
|
|
4
|
+
import { sh } from "../utils/shell.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Sync flow - fetch and pull from origin
|
|
8
|
+
*/
|
|
9
|
+
export async function flowSync() {
|
|
10
|
+
if (!git.isRepo()) {
|
|
11
|
+
note("Not a git repository.", "Error");
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const s = spinner();
|
|
16
|
+
s.start(pc.blue("📡 Fetching origin..."));
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
sh("git fetch origin");
|
|
20
|
+
const curr = git.branch();
|
|
21
|
+
|
|
22
|
+
// Check if branch exists on remote to avoid error
|
|
23
|
+
const remoteRef = sh(`git ls-remote --heads origin ${curr}`, true);
|
|
24
|
+
|
|
25
|
+
if (!remoteRef) {
|
|
26
|
+
s.stop(pc.yellow("⚠ No remote branch"));
|
|
27
|
+
note(
|
|
28
|
+
`Branch 'origin/${curr}' does not exist.\nPush your branch first to enable syncing.`,
|
|
29
|
+
"ℹ Info"
|
|
30
|
+
);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
s.message(pc.blue(`🔄 Pulling origin/${curr}...`));
|
|
35
|
+
|
|
36
|
+
// Explicitly pull from the remote matching current branch
|
|
37
|
+
sh(`git pull origin ${curr}`);
|
|
38
|
+
|
|
39
|
+
// Try to fix the upstream config for next time (silent)
|
|
40
|
+
try {
|
|
41
|
+
sh(`git branch --set-upstream-to=origin/${curr} ${curr}`, true);
|
|
42
|
+
} catch {}
|
|
43
|
+
|
|
44
|
+
s.stop(pc.green("✔ Sync Complete"));
|
|
45
|
+
} catch (e) {
|
|
46
|
+
s.stop(pc.red("✖ Sync Failed"));
|
|
47
|
+
note(e.message, "Git Error");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { spinner, select, confirm, isCancel, note } from "@clack/prompts";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import { git } from "../git/index.js";
|
|
4
|
+
import { sh } from "../utils/shell.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Undo/Rollback flow - reset to a previous commit
|
|
8
|
+
*/
|
|
9
|
+
export async function flowUndo() {
|
|
10
|
+
if (!git.isRepo()) {
|
|
11
|
+
note("Not a git repository.", "Error");
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const s = spinner();
|
|
16
|
+
s.start(pc.dim("Fetching history"));
|
|
17
|
+
const history = git.log(15);
|
|
18
|
+
s.stop(pc.dim("History loaded"));
|
|
19
|
+
|
|
20
|
+
if (!history.length) {
|
|
21
|
+
note("No commit history found to undo.", "ℹ Empty");
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const targetHash = await select({
|
|
26
|
+
message: "Reset branch to which commit?",
|
|
27
|
+
options: history.map((c, i) => {
|
|
28
|
+
const label = i === 0 ? `${c.msg} (Current)` : c.msg;
|
|
29
|
+
return {
|
|
30
|
+
value: c.hash,
|
|
31
|
+
label: `${pc.cyan(c.hash)} ${label}`,
|
|
32
|
+
hint: `${c.author}, ${c.time}`,
|
|
33
|
+
};
|
|
34
|
+
}),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
if (isCancel(targetHash)) return;
|
|
38
|
+
|
|
39
|
+
if (targetHash === history[0].hash) {
|
|
40
|
+
note("You selected the current commit. No changes made.", "ℹ Info");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const resetMode = await select({
|
|
45
|
+
message: "How should we reset?",
|
|
46
|
+
options: [
|
|
47
|
+
{ value: "--soft", label: "🧸 Soft Reset", hint: "Keep changes staged" },
|
|
48
|
+
{
|
|
49
|
+
value: "--mixed",
|
|
50
|
+
label: "🚧 Mixed Reset",
|
|
51
|
+
hint: "Keep changes in working dir",
|
|
52
|
+
},
|
|
53
|
+
{ value: "--hard", label: "🧨 Hard Reset", hint: "DESTROY changes" },
|
|
54
|
+
],
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
if (isCancel(resetMode)) return;
|
|
58
|
+
|
|
59
|
+
if (resetMode === "--hard") {
|
|
60
|
+
const safe = await confirm({
|
|
61
|
+
message: pc.red("⚠️ This will delete all uncommitted changes. Sure?"),
|
|
62
|
+
});
|
|
63
|
+
if (!safe || isCancel(safe)) return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const r = spinner();
|
|
67
|
+
r.start(pc.yellow(`Resetting to ${targetHash}...`));
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
sh(`git reset ${resetMode} ${targetHash}`);
|
|
71
|
+
r.stop(pc.green(`✔ Reset complete (${resetMode})`));
|
|
72
|
+
note(`HEAD is now at ${targetHash}`, "ℹ Reset Info");
|
|
73
|
+
} catch (e) {
|
|
74
|
+
r.stop(pc.red("✖ Reset failed"));
|
|
75
|
+
console.error(e.message);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { confirm, isCancel, spinner } from "@clack/prompts";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import { git } from "../git/index.js";
|
|
4
|
+
import { ui } from "../ui/index.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Check for updates and offer to pull if behind
|
|
8
|
+
*/
|
|
9
|
+
export async function checkForUpdates() {
|
|
10
|
+
if (!git.isRepo()) return;
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const { sh } = await import("../utils/shell.js");
|
|
14
|
+
sh("git fetch", true);
|
|
15
|
+
} catch {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let behind = 0;
|
|
20
|
+
const current = git.branch();
|
|
21
|
+
const defaultBr = git.defaultBranch();
|
|
22
|
+
|
|
23
|
+
// Smart check: If on main, check against origin/main regardless of upstream config
|
|
24
|
+
if (
|
|
25
|
+
defaultBr &&
|
|
26
|
+
(current === "main" ||
|
|
27
|
+
current === "master" ||
|
|
28
|
+
current === defaultBr.replace("origin/", ""))
|
|
29
|
+
) {
|
|
30
|
+
behind = parseInt(git.commitsBehind(defaultBr)) || 0;
|
|
31
|
+
} else {
|
|
32
|
+
behind = git.upstreamBehindCount();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (behind > 0) {
|
|
36
|
+
const shouldPull = await confirm({
|
|
37
|
+
message: `Your branch is behind by ${behind} commits. Pull them?`,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
if (isCancel(shouldPull)) process.exit(0);
|
|
41
|
+
|
|
42
|
+
if (shouldPull) {
|
|
43
|
+
const { sh } = await import("../utils/shell.js");
|
|
44
|
+
const s = spinner();
|
|
45
|
+
s.start(pc.blue("🔄 Pulling latest changes..."));
|
|
46
|
+
try {
|
|
47
|
+
sh(`git pull origin ${current}`);
|
|
48
|
+
s.stop(pc.green("✔ Updated"));
|
|
49
|
+
ui.banner();
|
|
50
|
+
} catch (e) {
|
|
51
|
+
s.stop(pc.red("✖ Pull Failed"));
|
|
52
|
+
const { note } = await import("@clack/prompts");
|
|
53
|
+
note(e.message);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import dotenv from "dotenv";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import os from "os";
|
|
5
|
+
|
|
6
|
+
dotenv.config();
|
|
7
|
+
|
|
8
|
+
const HOMEDIR = os.homedir();
|
|
9
|
+
const CONFIG_DIR = path.join(HOMEDIR, ".otto-cli");
|
|
10
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
11
|
+
|
|
12
|
+
function loadGlobalConfig() {
|
|
13
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
|
|
16
|
+
} catch (e) {
|
|
17
|
+
return {};
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function saveGlobalConfig(newConfig) {
|
|
24
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
25
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
26
|
+
}
|
|
27
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(newConfig, null, 2));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const globalConf = loadGlobalConfig();
|
|
31
|
+
|
|
32
|
+
// --- Configuration & Helpers ---
|
|
33
|
+
export let SHEET_WEBHOOK_URL =
|
|
34
|
+
process.env.GOOGLE_SHEET_WEBHOOK_URL || globalConf.GOOGLE_SHEET_WEBHOOK_URL;
|
|
35
|
+
export let OPENAI_API_KEY =
|
|
36
|
+
process.env.OPENAI_API_KEY || globalConf.OPENAI_API_KEY;
|
|
37
|
+
export const PM = fs.existsSync("pnpm-lock.yaml") ? "pnpm" : "npm";
|
|
38
|
+
|
|
39
|
+
export function updateConfig(key, value) {
|
|
40
|
+
const current = loadGlobalConfig();
|
|
41
|
+
const updated = { ...current, [key]: value };
|
|
42
|
+
saveGlobalConfig(updated);
|
|
43
|
+
|
|
44
|
+
if (key === "GOOGLE_SHEET_WEBHOOK_URL") SHEET_WEBHOOK_URL = value;
|
|
45
|
+
if (key === "OPENAI_API_KEY") OPENAI_API_KEY = value;
|
|
46
|
+
}
|
package/src/git/index.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { sh } from "../utils/shell.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Git helper functions
|
|
5
|
+
*/
|
|
6
|
+
export const git = {
|
|
7
|
+
isRepo: () => sh("git rev-parse --is-inside-work-tree", true) === "true",
|
|
8
|
+
|
|
9
|
+
branch: () => {
|
|
10
|
+
if (!git.isRepo()) return "no-git";
|
|
11
|
+
const b = sh("git symbolic-ref --short -q HEAD", true);
|
|
12
|
+
if (b) return b;
|
|
13
|
+
const d = sh("git rev-parse --short HEAD", true);
|
|
14
|
+
return d ? `detached@${d}` : "unborn";
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
defaultBranch: () => {
|
|
18
|
+
if (!git.isRepo()) return null;
|
|
19
|
+
try {
|
|
20
|
+
sh("git rev-parse --verify origin/main");
|
|
21
|
+
return "origin/main";
|
|
22
|
+
} catch {
|
|
23
|
+
try {
|
|
24
|
+
sh("git rev-parse --verify origin/master");
|
|
25
|
+
return "origin/master";
|
|
26
|
+
} catch {
|
|
27
|
+
return "main";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
commitInfo: (ref) => {
|
|
33
|
+
try {
|
|
34
|
+
const out = sh(`git log -1 --format="%h|%s|%ar" ${ref}`, true);
|
|
35
|
+
if (!out) return null;
|
|
36
|
+
const [hash, msg, time] = out.split("|");
|
|
37
|
+
return { hash, msg, time };
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
commitsBehind: (target) => {
|
|
44
|
+
try {
|
|
45
|
+
return sh(`git rev-list --count HEAD..${target}`, true);
|
|
46
|
+
} catch {
|
|
47
|
+
return "0";
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
upstreamBehindCount: () => {
|
|
52
|
+
if (!git.isRepo()) return 0;
|
|
53
|
+
try {
|
|
54
|
+
const count = sh("git rev-list --count HEAD..@{u}", true);
|
|
55
|
+
return parseInt(count) || 0;
|
|
56
|
+
} catch {
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
user: () => sh("git config user.name", true) || "Ghost",
|
|
62
|
+
|
|
63
|
+
diff: (staged = true) =>
|
|
64
|
+
sh(`git diff ${staged ? "--cached" : ""} --stat`, true),
|
|
65
|
+
|
|
66
|
+
rawDiff: () => sh("git diff --cached", true),
|
|
67
|
+
|
|
68
|
+
// Auto-stash for switching branches
|
|
69
|
+
stash: () => {
|
|
70
|
+
if (!git.isRepo()) return false;
|
|
71
|
+
const isDirty = sh("git status --porcelain", true).length > 0;
|
|
72
|
+
if (!isDirty) return false;
|
|
73
|
+
sh('git stash push -m "Otto Auto-Switch"', true);
|
|
74
|
+
return true;
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
// Manual stash with message
|
|
78
|
+
stashSave: (msg = "Otto Stash") => {
|
|
79
|
+
const isDirty = sh("git status --porcelain", true).length > 0;
|
|
80
|
+
if (!isDirty) throw new Error("No local changes to stash");
|
|
81
|
+
sh(`git stash push -m "${msg}"`);
|
|
82
|
+
return true;
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
// List all stashes
|
|
86
|
+
stashList: () => {
|
|
87
|
+
const out = sh("git stash list", true);
|
|
88
|
+
if (!out) return [];
|
|
89
|
+
// Output: stash@{0}: On main: message...
|
|
90
|
+
return out.split("\n").map((line) => {
|
|
91
|
+
const firstColon = line.indexOf(":");
|
|
92
|
+
const ref = line.substring(0, firstColon);
|
|
93
|
+
const msg = line.substring(firstColon + 1).trim();
|
|
94
|
+
return { ref, msg };
|
|
95
|
+
});
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
pop: () => {
|
|
99
|
+
if (!git.isRepo()) return false;
|
|
100
|
+
try {
|
|
101
|
+
sh("git stash pop");
|
|
102
|
+
return true;
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
log: (limit = 10) => {
|
|
109
|
+
if (!git.isRepo()) return [];
|
|
110
|
+
const out = sh(
|
|
111
|
+
`git log -n ${limit} --pretty=format:"%h|%s|%an|%ar"`,
|
|
112
|
+
true
|
|
113
|
+
);
|
|
114
|
+
if (!out) return [];
|
|
115
|
+
return out.split("\n").map((line) => {
|
|
116
|
+
const [hash, msg, author, time] = line.split("|");
|
|
117
|
+
return { hash, msg, author, time };
|
|
118
|
+
});
|
|
119
|
+
},
|
|
120
|
+
};
|
package/src/index.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { select, isCancel, outro } from "@clack/prompts";
|
|
3
|
+
import { ui } from "./ui/index.js";
|
|
4
|
+
import { ensureConfig } from "./utils/setup.js";
|
|
5
|
+
import {
|
|
6
|
+
checkForUpdates,
|
|
7
|
+
flowRelease,
|
|
8
|
+
flowBranch,
|
|
9
|
+
flowStash,
|
|
10
|
+
flowUndo,
|
|
11
|
+
flowSync,
|
|
12
|
+
flowBuild,
|
|
13
|
+
flowSettings,
|
|
14
|
+
} from "./commands/index.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Main interactive menu loop
|
|
18
|
+
*/
|
|
19
|
+
async function mainMenu() {
|
|
20
|
+
ui.banner();
|
|
21
|
+
await ensureConfig();
|
|
22
|
+
await checkForUpdates();
|
|
23
|
+
|
|
24
|
+
while (true) {
|
|
25
|
+
const op = await select({
|
|
26
|
+
message: "What's the plan?",
|
|
27
|
+
options: [
|
|
28
|
+
{ value: "release", label: "🚀 Release", hint: "Build, Tag, Push" },
|
|
29
|
+
{ value: "build", label: "🔨 Build", hint: "Install & Build" },
|
|
30
|
+
{ value: "branch", label: "🌿 Branch", hint: "Switch, Update, PR" },
|
|
31
|
+
{ value: "stash", label: "📦 Stash", hint: "Save & Pop Changes" },
|
|
32
|
+
{ value: "undo", label: "⏪ Rollback", hint: "Rollback Commits" },
|
|
33
|
+
{ value: "sync", label: "🔄 Sync", hint: "Fetch & Pull latest" },
|
|
34
|
+
{ value: "settings", label: "⚙️. Settings", hint: "Config API & Sheets" },
|
|
35
|
+
{ value: "quit", label: "🚪 Quit" },
|
|
36
|
+
],
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
if (isCancel(op) || op === "quit") {
|
|
40
|
+
outro("👋 Bye!");
|
|
41
|
+
process.exit(0);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
if (op === "release") await flowRelease();
|
|
46
|
+
if (op === "build") await flowBuild();
|
|
47
|
+
if (op === "branch") await flowBranch();
|
|
48
|
+
if (op === "stash") await flowStash();
|
|
49
|
+
if (op === "undo") await flowUndo();
|
|
50
|
+
if (op === "sync") await flowSync();
|
|
51
|
+
if (op === "settings") await flowSettings();
|
|
52
|
+
} catch (e) {
|
|
53
|
+
const { note } = await import("@clack/prompts");
|
|
54
|
+
note(e.message, "⚠ Unexpected Error");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
console.log("");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Setup and run the CLI program
|
|
63
|
+
*/
|
|
64
|
+
export async function run() {
|
|
65
|
+
const program = new Command();
|
|
66
|
+
program.name("otto").description("AI-powered Release CLI").version("3.1.0");
|
|
67
|
+
|
|
68
|
+
program.command("release").action(async () => {
|
|
69
|
+
ui.banner();
|
|
70
|
+
await ensureConfig();
|
|
71
|
+
await checkForUpdates();
|
|
72
|
+
await flowRelease();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
program.command("branch").action(async () => {
|
|
76
|
+
ui.banner();
|
|
77
|
+
await checkForUpdates();
|
|
78
|
+
await flowBranch();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
program.command("stash").action(async () => {
|
|
82
|
+
ui.banner();
|
|
83
|
+
await checkForUpdates();
|
|
84
|
+
await flowStash();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
program.command("undo").action(async () => {
|
|
88
|
+
ui.banner();
|
|
89
|
+
await checkForUpdates();
|
|
90
|
+
await flowUndo();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
program.command("sync").action(async () => {
|
|
94
|
+
ui.banner();
|
|
95
|
+
await checkForUpdates();
|
|
96
|
+
await flowSync();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
program.command("settings").action(async () => {
|
|
100
|
+
ui.banner();
|
|
101
|
+
await ensureConfig();
|
|
102
|
+
await checkForUpdates();
|
|
103
|
+
await flowSettings();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
program.command("build").action(async () => {
|
|
107
|
+
ui.banner();
|
|
108
|
+
await checkForUpdates();
|
|
109
|
+
await flowBuild();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (!process.argv.slice(2).length) {
|
|
113
|
+
await mainMenu();
|
|
114
|
+
} else {
|
|
115
|
+
program.parse(process.argv);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import OpenAI from "openai";
|
|
2
|
+
import { spinner } from "@clack/prompts";
|
|
3
|
+
import pc from "picocolors";
|
|
4
|
+
import { OPENAI_API_KEY } from "../config.js";
|
|
5
|
+
import { ui } from "../ui/index.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generate commit message using OpenAI
|
|
9
|
+
* @param {string} diff - Git diff to analyze
|
|
10
|
+
* @returns {Promise<{msg: string, desc: string}>} Generated message and description
|
|
11
|
+
*/
|
|
12
|
+
export async function generateCommit(diff) {
|
|
13
|
+
if (!OPENAI_API_KEY) ui.die("Missing OPENAI_API_KEY");
|
|
14
|
+
|
|
15
|
+
const openai = new OpenAI({ apiKey: OPENAI_API_KEY });
|
|
16
|
+
const s = spinner();
|
|
17
|
+
s.start(pc.magenta("🤖 AI Analyzing changes"));
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const res = await openai.chat.completions.create({
|
|
21
|
+
model: "gpt-4o-mini",
|
|
22
|
+
messages: [
|
|
23
|
+
{
|
|
24
|
+
role: "user",
|
|
25
|
+
content:
|
|
26
|
+
`Analyze diff, return JSON with "msg" (conventional commit) and "desc" (technical summary):\n` +
|
|
27
|
+
diff.substring(0, 15000),
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
response_format: { type: "json_object" },
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
s.stop(pc.green("✔ AI Analysis Complete"));
|
|
34
|
+
return JSON.parse(res.choices?.[0]?.message?.content || "{}");
|
|
35
|
+
} catch (e) {
|
|
36
|
+
s.stop(pc.red("✖ AI Failed"));
|
|
37
|
+
throw e;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { SHEET_WEBHOOK_URL } from "../config.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Log data to Google Sheets via webhook
|
|
5
|
+
* @param {Object} data - Data to log
|
|
6
|
+
*/
|
|
7
|
+
export async function logToSheet(data) {
|
|
8
|
+
if (!SHEET_WEBHOOK_URL) return;
|
|
9
|
+
try {
|
|
10
|
+
await fetch(SHEET_WEBHOOK_URL, {
|
|
11
|
+
method: "POST",
|
|
12
|
+
headers: { "Content-Type": "application/json" },
|
|
13
|
+
body: JSON.stringify(data),
|
|
14
|
+
});
|
|
15
|
+
} catch {
|
|
16
|
+
// ignore
|
|
17
|
+
}
|
|
18
|
+
}
|