huddle-cli 0.1.3 → 0.1.5

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.
@@ -0,0 +1,131 @@
1
+ // `huddle undo`: take back what a run merged. It adds one commit that reverts
2
+ // the run's commits (history stays; nothing is force-pushed), so an undo can
3
+ // itself be undone with git revert.
4
+ //
5
+ // huddle undo the last run (asks first; --yes doesn't)
6
+ // huddle undo --run 3 that run (numbers from `huddle runs`)
7
+ import fs from "node:fs";
8
+ import readline from "node:readline";
9
+ import { spawnSync } from "node:child_process";
10
+ import { C } from "./term.js";
11
+ import { pickRun, splitRuns } from "./story.js";
12
+ const git = (root, args) => {
13
+ const r = spawnSync("git", args, { cwd: root, encoding: "utf8" });
14
+ return { ok: r.status === 0, out: (r.stdout ?? "").trim(), err: (r.stderr ?? "").trim() };
15
+ };
16
+ /** What undoing run `which` would revert, or why it can't. */
17
+ export function undoPlan(root, events, which) {
18
+ const runs = splitRuns(events);
19
+ const run = pickRun(runs, which);
20
+ if (!run)
21
+ return { ok: false, why: runs.length ? `no run ${which} (see huddle runs)` : "no runs here yet" };
22
+ const base = { index: run.index, goal: run.goal, id: run.id };
23
+ // An undo is recorded when it happens, so it lands in whichever run is the
24
+ // latest by then: look in every run for one that names this run.
25
+ const undone = runs.some((r) => r.events.some((e) => e.type === "git.undone" && (e.p.id ? e.p.id === run.id : Number(e.p.run) === run.index)));
26
+ if (undone)
27
+ return { ...base, ok: false, why: `run ${run.index} is already undone` };
28
+ const g = run.events.filter((e) => e.type === "git.range").at(-1);
29
+ if (!g)
30
+ return { ...base, ok: false, why: `run ${run.index} has no record of its commits (it ran before Huddle 0.1.4, or didn't finish)` };
31
+ const start = String(g.p.start ?? "");
32
+ const end = String(g.p.end ?? "");
33
+ if (!start || !end || start === end)
34
+ return { ...base, ok: false, why: `run ${run.index} didn't merge anything` };
35
+ if (!git(root, ["merge-base", "--is-ancestor", end, "HEAD"]).ok)
36
+ return { ...base, ok: false, why: `run ${run.index}'s commits aren't on this branch (${git(root, ["symbolic-ref", "--short", "HEAD"]).out || "detached HEAD"})` };
37
+ const commits = git(root, ["log", "--format=%h %s", `${start}..${end}`]).out.split("\n").filter(Boolean);
38
+ return { ...base, ok: true, start, end, commits };
39
+ }
40
+ /** The run's goal as one short line, for the revert commit and the screen. */
41
+ export const undoTitle = (plan) => String(plan.goal ?? "").split("\n")[0].slice(0, 80);
42
+ /**
43
+ * Carries out a plan from undoPlan: the revert commit, then the git.undone
44
+ * event that marks the run undone. `huddle undo` and the dashboard both go
45
+ * through here, so an undo is recorded the same way wherever you clicked it.
46
+ */
47
+ export function undoRun(root, db, plan) {
48
+ if (!plan.ok)
49
+ return { ok: false, why: plan.why };
50
+ const goal = undoTitle(plan);
51
+ const r = revertRange(root, plan.start, plan.end, `huddle: undo run ${plan.index} (${goal})\n\nReverts ${plan.start.slice(0, 7)}..${plan.end.slice(0, 7)}.`);
52
+ if (!r.ok)
53
+ return { ok: false, why: r.why };
54
+ db.publish("git.undone", { payload: { run: plan.index, ...(plan.id ? { id: plan.id } : {}), commit: r.commit } });
55
+ const up = git(root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
56
+ const pushed = up.ok && git(root, ["merge-base", "--is-ancestor", plan.end, up.out]).ok;
57
+ return { ok: true, commit: r.commit, ...(pushed ? { pushedTo: up.out } : {}) };
58
+ }
59
+ /** Reverts start..end as one commit. A conflict (later work built on it) leaves the tree as it was. */
60
+ export function revertRange(root, start, end, message) {
61
+ const dirty = git(root, ["status", "--porcelain", "--untracked-files=no"]).out;
62
+ if (dirty)
63
+ return { ok: false, why: "you have uncommitted changes — commit or stash them first" };
64
+ const r = git(root, ["revert", "--no-edit", "--no-commit", `${start}..${end}`]);
65
+ if (!r.ok) {
66
+ git(root, ["revert", "--abort"]);
67
+ git(root, ["reset", "--hard", "-q", "HEAD"]);
68
+ return { ok: false, why: "later commits build on this run's work, so it can't be taken back cleanly — revert by hand: git revert " + `${start.slice(0, 7)}..${end.slice(0, 7)}` };
69
+ }
70
+ if (!git(root, ["status", "--porcelain", "--untracked-files=no"]).out) {
71
+ git(root, ["revert", "--quit"]);
72
+ return { ok: false, why: "nothing to undo: the run's changes are already gone from this branch" };
73
+ }
74
+ const c = git(root, ["commit", "-q", "--no-verify", "-m", message]);
75
+ if (!c.ok) {
76
+ git(root, ["revert", "--abort"]);
77
+ return { ok: false, why: c.err.split("\n")[0] || "git commit failed" };
78
+ }
79
+ git(root, ["revert", "--quit"]);
80
+ return { ok: true, commit: git(root, ["rev-parse", "--short", "HEAD"]).out };
81
+ }
82
+ async function confirm(q) {
83
+ if (!process.stdin.isTTY)
84
+ return false;
85
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
86
+ const a = await new Promise((r) => rl.question(q, r));
87
+ rl.close();
88
+ return /^y(es)?$/i.test(a.trim());
89
+ }
90
+ export async function runUndo(args) {
91
+ const { dbPath, repoRoot } = await import("./app.js");
92
+ if (!fs.existsSync(dbPath())) {
93
+ console.log(C.faint(" no runs here yet — nothing to undo."));
94
+ return 0;
95
+ }
96
+ const i = args.indexOf("--run");
97
+ const which = i >= 0 ? args[i + 1] : args.find((a) => /^\d+$/.test(a));
98
+ const { RoomDB } = await import("../room/db.js");
99
+ const db = new RoomDB(dbPath());
100
+ try {
101
+ const root = repoRoot();
102
+ const plan = undoPlan(root, db.allEvents(), which);
103
+ if (!plan.ok) {
104
+ console.log(C.red("✗ ") + plan.why);
105
+ return 1;
106
+ }
107
+ console.log(`Undo run ${plan.index}: ${C.bold(undoTitle(plan))}`);
108
+ console.log(C.faint(` it reverts ${plan.commits.length} commit(s), as one new commit:`));
109
+ for (const c of plan.commits.slice(0, 12))
110
+ console.log(C.faint(` ${c}`));
111
+ if (plan.commits.length > 12)
112
+ console.log(C.faint(` … and ${plan.commits.length - 12} more`));
113
+ if (!args.includes("--yes") && !args.includes("-y") && !(await confirm(" Undo it? [y/N] "))) {
114
+ console.log(C.faint(process.stdin.isTTY ? " left as it is." : " not a terminal: add --yes to undo."));
115
+ return process.stdin.isTTY ? 0 : 1;
116
+ }
117
+ const r = undoRun(root, db, plan);
118
+ if (!r.ok) {
119
+ console.log(C.red("✗ ") + r.why);
120
+ return 1;
121
+ }
122
+ console.log(`${C.green("✓")} undone in ${r.commit}. ${C.faint(`Changed your mind? git revert ${r.commit}`)}`);
123
+ if (r.pushedTo)
124
+ console.log(C.faint(` the run was already pushed to ${r.pushedTo}: git push sends the undo there too`));
125
+ return 0;
126
+ }
127
+ finally {
128
+ db.close();
129
+ }
130
+ }
131
+ //# sourceMappingURL=undo.js.map
package/dist/update.js ADDED
@@ -0,0 +1,84 @@
1
+ // `huddle update`: get the newest Huddle from npm. Found live (0.1.3): the user
2
+ // typed `huddle --update`, the way other CLIs work, and got "unknown command".
3
+ import { fileURLToPath } from "node:url";
4
+ import { packageInfo } from "./preflight.js";
5
+ import { spawnToolSync } from "./platform.js";
6
+ import { C } from "./ui/term.js";
7
+ /** -1, 0 or 1, comparing x.y.z (a pre-release tag sorts before its release). */
8
+ export function compareVersions(a, b) {
9
+ const parse = (v) => {
10
+ const [core, pre] = v.trim().replace(/^v/, "").split("-", 2);
11
+ return { nums: core.split(".").map((n) => Number(n) || 0), pre: pre ?? "" };
12
+ };
13
+ const x = parse(a);
14
+ const y = parse(b);
15
+ for (let i = 0; i < 3; i++) {
16
+ const d = (x.nums[i] ?? 0) - (y.nums[i] ?? 0);
17
+ if (d)
18
+ return Math.sign(d);
19
+ }
20
+ if (x.pre === y.pre)
21
+ return 0;
22
+ if (!x.pre)
23
+ return 1;
24
+ if (!y.pre)
25
+ return -1;
26
+ return x.pre < y.pre ? -1 : 1;
27
+ }
28
+ /**
29
+ * What `huddle update` should do. Only an npm install (a path through
30
+ * node_modules/huddle-cli) is replaced with npm; a source checkout is told how
31
+ * to update itself instead of getting a second, global copy.
32
+ */
33
+ export function updatePlan(current, latest, cliPath) {
34
+ if (!latest)
35
+ return { kind: "offline" };
36
+ const c = compareVersions(current, latest);
37
+ if (c === 0)
38
+ return { kind: "latest", current };
39
+ if (c > 0)
40
+ return { kind: "ahead", current, latest };
41
+ if (!/[\\/]node_modules[\\/]huddle-cli[\\/]/.test(cliPath)) {
42
+ return { kind: "source", current, latest, dir: cliPath.replace(/[\\/]dist[\\/].*$/, "") };
43
+ }
44
+ return { kind: "install", current, latest };
45
+ }
46
+ /** The newest huddle-cli on npm, or null when npm can't be reached. */
47
+ export function latestOnNpm() {
48
+ const r = spawnToolSync(["npm", "view", "huddle-cli", "version", "--prefer-online"], { encoding: "utf8", timeout: 30_000 });
49
+ const v = r.status === 0 ? r.stdout.trim().split(/\s+/).pop() ?? "" : "";
50
+ return /^\d+\.\d+\.\d+/.test(v) ? v : null;
51
+ }
52
+ export function runUpdate() {
53
+ const current = packageInfo().version;
54
+ console.log(C.faint(`huddle ${current} · checking npm…`));
55
+ const plan = updatePlan(current, latestOnNpm(), fileURLToPath(import.meta.url));
56
+ switch (plan.kind) {
57
+ case "offline":
58
+ console.log(C.red("✗ couldn't reach npm.") + " Check your connection, or run: npm install -g huddle-cli@latest");
59
+ return 1;
60
+ case "latest":
61
+ console.log(C.green("✓") + ` huddle ${plan.current} is the newest version.`);
62
+ return 0;
63
+ case "ahead":
64
+ console.log(C.green("✓") + ` huddle ${plan.current} is newer than npm's ${plan.latest} (a preview build). Nothing to do.`);
65
+ return 0;
66
+ case "source":
67
+ console.log(`huddle ${plan.latest} is out; this huddle runs from a source checkout (${plan.dir}).`);
68
+ console.log(C.faint(" update it there: git pull && npm install && npm run build"));
69
+ return 0;
70
+ case "install": {
71
+ console.log(`updating huddle ${plan.current} → ${C.violet(plan.latest)}`);
72
+ const r = spawnToolSync(["npm", "install", "-g", `huddle-cli@${plan.latest}`], { stdio: "inherit" });
73
+ if (r.status !== 0) {
74
+ console.log(C.red("✗ npm couldn't install it.") + ` Try it yourself: npm install -g huddle-cli@${plan.latest}`);
75
+ if (process.platform !== "win32")
76
+ console.log(C.faint(" a permissions error means npm's global folder needs sudo; nvm avoids that"));
77
+ return 1;
78
+ }
79
+ console.log(C.green("✓") + ` huddle ${plan.latest} installed.` + C.faint(" Open a new terminal (or run hash -r) if `huddle --version` still shows the old one."));
80
+ return 0;
81
+ }
82
+ }
83
+ }
84
+ //# sourceMappingURL=update.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "huddle-cli",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "The multiplayer layer for AI coding agents. One command. Every agent. One repo. Zero collisions.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -51,6 +51,7 @@
51
51
  "dependencies": {
52
52
  "@modelcontextprotocol/sdk": "^1.0.0",
53
53
  "nanoid": "^5.0.0",
54
+ "qrcode-terminal": "0.12.0",
54
55
  "ws": "^8.18.0"
55
56
  },
56
57
  "optionalDependencies": {
@@ -59,6 +60,7 @@
59
60
  "devDependencies": {
60
61
  "@types/node": "^22.0.0",
61
62
  "@types/ws": "^8.5.0",
63
+ "jsqr": "1.4.0",
62
64
  "puppeteer": "^24.43.1",
63
65
  "typescript": "^5.6.0"
64
66
  }