huddle-cli 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +357 -2
- package/dist/adapters/amp.js +40 -0
- package/dist/adapters/antigravity.js +52 -0
- package/dist/adapters/claude.js +70 -0
- package/dist/adapters/codex.js +59 -0
- package/dist/adapters/copilot.js +69 -0
- package/dist/adapters/cursor.js +85 -0
- package/dist/adapters/custom.js +340 -0
- package/dist/adapters/droid.js +57 -0
- package/dist/adapters/gemini.js +61 -0
- package/dist/adapters/goose.js +55 -0
- package/dist/adapters/index.js +135 -0
- package/dist/adapters/kiro.js +41 -0
- package/dist/adapters/opencode.js +63 -0
- package/dist/adapters/qwen.js +67 -0
- package/dist/adapters/types.js +6 -0
- package/dist/adapters/usage.js +141 -0
- package/dist/attach/connect.js +409 -0
- package/dist/attach/guard.js +64 -0
- package/dist/attach/live.js +222 -0
- package/dist/attach/plug.js +174 -0
- package/dist/attach/session.js +172 -0
- package/dist/cli.js +176 -0
- package/dist/mcp/server.js +240 -0
- package/dist/orchestrator/ask.js +51 -0
- package/dist/orchestrator/brain.js +164 -0
- package/dist/orchestrator/council.js +274 -0
- package/dist/orchestrator/handoff.js +117 -0
- package/dist/orchestrator/launcher.js +155 -0
- package/dist/orchestrator/planner.js +194 -0
- package/dist/orchestrator/qa.js +350 -0
- package/dist/orchestrator/scheduler.js +748 -0
- package/dist/orchestrator/takeover.js +85 -0
- package/dist/preflight.js +31 -0
- package/dist/relay/server.js +113 -0
- package/dist/room/db.js +617 -0
- package/dist/room/identity.js +122 -0
- package/dist/room/live.js +293 -0
- package/dist/room/progress.js +110 -0
- package/dist/room/relaylink.js +85 -0
- package/dist/room/server.js +752 -0
- package/dist/room/sqlite.js +156 -0
- package/dist/room/transport.js +107 -0
- package/dist/room/tunnel.js +85 -0
- package/dist/sandbox/sync.js +104 -0
- package/dist/sandbox/worktree.js +261 -0
- package/dist/types.js +4 -0
- package/dist/ui/app.js +1534 -0
- package/dist/ui/catalog.js +75 -0
- package/dist/ui/dashboard.js +192 -0
- package/dist/ui/doctor.js +54 -0
- package/dist/ui/logo.js +40 -0
- package/dist/ui/remote.js +332 -0
- package/dist/ui/story.js +932 -0
- package/dist/ui/term.js +114 -0
- package/dist/ui/watch.js +174 -0
- package/install.sh +44 -0
- package/package.json +60 -4
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// The Room's SQLite driver, with a fallback so a fresh machine never dead-ends
|
|
2
|
+
// on a native build. Preferred: better-sqlite3 (fast, proven, but a native
|
|
3
|
+
// addon — it needs a prebuilt binary for this Node, or a C++ toolchain). If it
|
|
4
|
+
// didn't install or was built for another Node version, Huddle uses Node's
|
|
5
|
+
// built-in `node:sqlite` (Node 22.13+ without flags). Neither → one clear,
|
|
6
|
+
// actionable message instead of a stack trace.
|
|
7
|
+
//
|
|
8
|
+
// Only the slice of the API RoomDB uses is wrapped: exec / prepare().run|get|all
|
|
9
|
+
// (positional or @named params) / pragma / transaction / close.
|
|
10
|
+
//
|
|
11
|
+
// HUDDLE_SQLITE=node force node:sqlite (tests, or to skip a broken addon)
|
|
12
|
+
// HUDDLE_SQLITE=better force better-sqlite3 (no fallback)
|
|
13
|
+
import { createRequire } from "node:module";
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
/** A message meant for the user, printed as-is by the CLI (no stack). */
|
|
16
|
+
export class SqliteUnavailableError extends Error {
|
|
17
|
+
}
|
|
18
|
+
function short(e) {
|
|
19
|
+
let m = String(e?.message ?? e).split("\n")[0];
|
|
20
|
+
// Say what the usual addon failures mean instead of echoing their internals.
|
|
21
|
+
if (/Could not locate the bindings file/i.test(m))
|
|
22
|
+
m = "its native binary isn't built for this machine";
|
|
23
|
+
else if (/NODE_MODULE_VERSION|compiled against a different Node/i.test(m))
|
|
24
|
+
m = "its native binary was built for a different Node version";
|
|
25
|
+
else if (/Cannot find (module|package) 'better-sqlite3'/i.test(m))
|
|
26
|
+
m = "not installed (it's optional, and its build was skipped or failed)";
|
|
27
|
+
return m.length > 160 ? m.slice(0, 159) + "…" : m;
|
|
28
|
+
}
|
|
29
|
+
function openBetter(path) {
|
|
30
|
+
const Database = require("better-sqlite3");
|
|
31
|
+
const db = new Database(path); // loads the native addon; throws if it's missing/mismatched
|
|
32
|
+
return {
|
|
33
|
+
driver: "better-sqlite3",
|
|
34
|
+
exec: (sql) => void db.exec(sql),
|
|
35
|
+
prepare: (sql) => db.prepare(sql),
|
|
36
|
+
pragma: (sql) => void db.pragma(sql),
|
|
37
|
+
transaction: (fn) => db.transaction(fn),
|
|
38
|
+
close: () => db.close(),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** node:sqlite, loaded synchronously, with its one-time ExperimentalWarning muted. */
|
|
42
|
+
function loadNodeSqlite() {
|
|
43
|
+
const emit = process.emitWarning;
|
|
44
|
+
process.emitWarning = ((w, ...rest) => {
|
|
45
|
+
const text = String(w?.message ?? w);
|
|
46
|
+
const type = typeof rest[0] === "string" ? rest[0] : rest[0]?.type ?? w?.name;
|
|
47
|
+
if (type === "ExperimentalWarning" && /sqlite/i.test(text))
|
|
48
|
+
return;
|
|
49
|
+
return emit.call(process, w, ...rest);
|
|
50
|
+
});
|
|
51
|
+
try {
|
|
52
|
+
return require("node:sqlite");
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
process.emitWarning = emit;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// node:sqlite rows have a null prototype; hand back plain objects like better-sqlite3.
|
|
59
|
+
const plain = (row) => (row && typeof row === "object" ? { ...row } : row);
|
|
60
|
+
function openNode(path) {
|
|
61
|
+
const { DatabaseSync } = loadNodeSqlite();
|
|
62
|
+
const db = new DatabaseSync(path);
|
|
63
|
+
// better-sqlite3 waits up to 5s on a locked db; node:sqlite defaults to 0.
|
|
64
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
65
|
+
let depth = 0;
|
|
66
|
+
return {
|
|
67
|
+
driver: "node:sqlite",
|
|
68
|
+
exec: (sql) => db.exec(sql),
|
|
69
|
+
prepare: (sql) => {
|
|
70
|
+
const st = db.prepare(sql);
|
|
71
|
+
return {
|
|
72
|
+
run: (...p) => st.run(...p),
|
|
73
|
+
get: (...p) => plain(st.get(...p)) ?? undefined,
|
|
74
|
+
all: (...p) => st.all(...p).map(plain),
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
pragma: (sql) => db.exec(`PRAGMA ${sql}`),
|
|
78
|
+
transaction: (fn) => () => {
|
|
79
|
+
// Nested calls run inside the outer transaction (better-sqlite3 uses savepoints; we don't nest).
|
|
80
|
+
if (depth > 0)
|
|
81
|
+
return fn();
|
|
82
|
+
db.exec("BEGIN");
|
|
83
|
+
depth++;
|
|
84
|
+
try {
|
|
85
|
+
const r = fn();
|
|
86
|
+
db.exec("COMMIT");
|
|
87
|
+
return r;
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
db.exec("ROLLBACK");
|
|
91
|
+
throw e;
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
depth--;
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
close: () => db.close(),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** Open the Room db with the best driver available on this machine. */
|
|
101
|
+
export function openDatabase(path) {
|
|
102
|
+
const want = process.env.HUDDLE_SQLITE;
|
|
103
|
+
if (want === "node")
|
|
104
|
+
return openNodeOrExplain(path, null);
|
|
105
|
+
try {
|
|
106
|
+
return openBetter(path);
|
|
107
|
+
}
|
|
108
|
+
catch (e) {
|
|
109
|
+
// A bad path / permissions isn't a driver problem — don't mask it.
|
|
110
|
+
if (e?.code === "SQLITE_CANTOPEN" || /unable to open database/i.test(short(e)))
|
|
111
|
+
throw e;
|
|
112
|
+
if (want === "better")
|
|
113
|
+
throw explain(e, null);
|
|
114
|
+
return openNodeOrExplain(path, e);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function openNodeOrExplain(path, betterErr) {
|
|
118
|
+
try {
|
|
119
|
+
return openNode(path);
|
|
120
|
+
}
|
|
121
|
+
catch (e) {
|
|
122
|
+
// node:sqlite exists but the db itself failed (path, permissions) — show that.
|
|
123
|
+
if (/unable to open|SQLITE_CANTOPEN/i.test(short(e)))
|
|
124
|
+
throw e;
|
|
125
|
+
throw explain(betterErr, e);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function explain(betterErr, nodeErr) {
|
|
129
|
+
const v = process.versions.node;
|
|
130
|
+
const major = Number(v.split(".")[0]);
|
|
131
|
+
const minor = Number(v.split(".")[1]);
|
|
132
|
+
const builtin = major > 22 || (major === 22 && minor >= 13);
|
|
133
|
+
const lines = [`Huddle couldn't open its room database (SQLite) on Node ${v}.`];
|
|
134
|
+
if (betterErr)
|
|
135
|
+
lines.push(` better-sqlite3: ${short(betterErr)}`);
|
|
136
|
+
if (nodeErr)
|
|
137
|
+
lines.push(` node:sqlite: ${short(nodeErr)}`);
|
|
138
|
+
lines.push("", "Fix it with one of these:", " • reinstall, so the native module is built for this Node: npm install -g huddle-cli");
|
|
139
|
+
// On 22.13+ node:sqlite was already tried (and its error is above), so only suggest it to older Nodes.
|
|
140
|
+
if (!builtin)
|
|
141
|
+
lines.push(" • or use Node 22.13+ — Huddle then falls back to Node's built-in SQLite, nothing to build");
|
|
142
|
+
lines.push(" • building it needs a C++ toolchain: macOS `xcode-select --install` · Debian/Ubuntu `sudo apt install build-essential python3`");
|
|
143
|
+
return new SqliteUnavailableError(lines.join("\n"));
|
|
144
|
+
}
|
|
145
|
+
/** Which driver works here, or why none does — for `huddle doctor`. Opens nothing on disk. */
|
|
146
|
+
export function sqliteStatus() {
|
|
147
|
+
try {
|
|
148
|
+
const db = openDatabase(":memory:");
|
|
149
|
+
db.close();
|
|
150
|
+
return { ok: true, driver: db.driver };
|
|
151
|
+
}
|
|
152
|
+
catch (e) {
|
|
153
|
+
return { ok: false, message: String(e?.message ?? e) };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
//# sourceMappingURL=sqlite.js.map
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// How a teammate's Huddle reaches a room. Two ways, same shared API:
|
|
2
|
+
// direct http(s)://host:port — Wi-Fi, Tailscale, or a tunnel
|
|
3
|
+
// relay wss://relay/r/<room> — both sides dial out to a relay; works across any networks
|
|
4
|
+
// Either way the host's Room enforces everything; transports just carry bytes.
|
|
5
|
+
import { WebSocket } from "ws";
|
|
6
|
+
export function transportFor(address) {
|
|
7
|
+
const a = address.trim().replace(/\/+$/, "");
|
|
8
|
+
if (/^wss?:\/\//.test(a))
|
|
9
|
+
return relayTransport(a);
|
|
10
|
+
return directTransport(/^https?:\/\//.test(a) ? a : `http://${a}`);
|
|
11
|
+
}
|
|
12
|
+
function directTransport(base) {
|
|
13
|
+
let ws = null;
|
|
14
|
+
return {
|
|
15
|
+
describe: base,
|
|
16
|
+
async request(method, path, body, token) {
|
|
17
|
+
const r = await fetch(`${base}${path}`, {
|
|
18
|
+
method,
|
|
19
|
+
headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
|
20
|
+
body: method === "POST" ? JSON.stringify(body ?? {}) : undefined,
|
|
21
|
+
signal: AbortSignal.timeout(20_000),
|
|
22
|
+
});
|
|
23
|
+
return { status: r.status, body: await r.json().catch(() => ({})) };
|
|
24
|
+
},
|
|
25
|
+
events(token, onEvent, onClose) {
|
|
26
|
+
ws = new WebSocket(`${base.replace(/^http/, "ws")}/?token=${encodeURIComponent(token)}`);
|
|
27
|
+
ws.on("message", (raw) => {
|
|
28
|
+
try {
|
|
29
|
+
onEvent(JSON.parse(String(raw)));
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
/* ignore */
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
ws.on("close", (code) => onClose(code));
|
|
36
|
+
ws.on("error", () => { });
|
|
37
|
+
},
|
|
38
|
+
close() {
|
|
39
|
+
ws?.close();
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Relay: one WebSocket to the relay carries both request/response and events.
|
|
45
|
+
* Frames: {t:"req", id, method, path, body, token} → {t:"res", id, status, body}
|
|
46
|
+
* {t:"sub", token} → {t:"event", e} … and {t:"closed", code}
|
|
47
|
+
*/
|
|
48
|
+
function relayTransport(url) {
|
|
49
|
+
const ws = new WebSocket(url.includes("/r/") ? url.replace("/r/", "/join/") : url);
|
|
50
|
+
const ready = new Promise((resolve, reject) => {
|
|
51
|
+
ws.once("open", () => resolve());
|
|
52
|
+
ws.once("error", (e) => reject(e));
|
|
53
|
+
});
|
|
54
|
+
let n = 0;
|
|
55
|
+
const pending = new Map();
|
|
56
|
+
let onEvent = null;
|
|
57
|
+
let onClose = null;
|
|
58
|
+
ws.on("message", (raw) => {
|
|
59
|
+
let f;
|
|
60
|
+
try {
|
|
61
|
+
f = JSON.parse(String(raw));
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (f.t === "res")
|
|
67
|
+
pending.get(f.id)?.({ status: f.status, body: f.body }), pending.delete(f.id);
|
|
68
|
+
else if (f.t === "event")
|
|
69
|
+
onEvent?.(f.e);
|
|
70
|
+
else if (f.t === "closed")
|
|
71
|
+
onClose?.(f.code ?? 1000);
|
|
72
|
+
});
|
|
73
|
+
ws.on("close", (code) => {
|
|
74
|
+
for (const r of pending.values())
|
|
75
|
+
r({ status: 503, body: { error: "relay connection closed" } });
|
|
76
|
+
pending.clear();
|
|
77
|
+
onClose?.(code === 1000 ? 1000 : 4500);
|
|
78
|
+
});
|
|
79
|
+
ws.on("error", () => { });
|
|
80
|
+
return {
|
|
81
|
+
describe: url,
|
|
82
|
+
async request(method, path, body, token) {
|
|
83
|
+
await ready;
|
|
84
|
+
const id = ++n;
|
|
85
|
+
return new Promise((resolve) => {
|
|
86
|
+
const timer = setTimeout(() => {
|
|
87
|
+
pending.delete(id);
|
|
88
|
+
resolve({ status: 504, body: { error: "room didn't answer through the relay (is the host's Huddle open?)" } });
|
|
89
|
+
}, 20_000);
|
|
90
|
+
pending.set(id, (r) => {
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
resolve(r);
|
|
93
|
+
});
|
|
94
|
+
ws.send(JSON.stringify({ t: "req", id, method, path, body: body ?? {}, token }));
|
|
95
|
+
});
|
|
96
|
+
},
|
|
97
|
+
events(token, ev, close) {
|
|
98
|
+
onEvent = ev;
|
|
99
|
+
onClose = close;
|
|
100
|
+
void ready.then(() => ws.send(JSON.stringify({ t: "sub", token })));
|
|
101
|
+
},
|
|
102
|
+
close() {
|
|
103
|
+
ws.close(1000);
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
//# sourceMappingURL=transport.js.map
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Making the shared door reachable from anywhere, not just your Wi-Fi.
|
|
2
|
+
// Uses Cloudflare's free quick tunnels (`cloudflared tunnel --url …`): no
|
|
3
|
+
// account, no config, a random https URL that dies when Huddle exits. Only the
|
|
4
|
+
// shared door is exposed — your agents' local door stays on 127.0.0.1.
|
|
5
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
6
|
+
export function hasCloudflared() {
|
|
7
|
+
return spawnSync(process.platform === "win32" ? "where" : "which", ["cloudflared"], { stdio: "pipe" }).status === 0;
|
|
8
|
+
}
|
|
9
|
+
export async function openTunnel(port, log, timeoutMs = 25_000) {
|
|
10
|
+
if (!hasCloudflared() || process.env.HUDDLE_NO_TUNNEL)
|
|
11
|
+
return null;
|
|
12
|
+
log(" opening a public tunnel (cloudflared)…");
|
|
13
|
+
const child = spawn("cloudflared", ["tunnel", "--no-autoupdate", "--url", `http://127.0.0.1:${port}`], {
|
|
14
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
15
|
+
});
|
|
16
|
+
const url = await new Promise((resolve) => {
|
|
17
|
+
const timer = setTimeout(() => resolve(null), timeoutMs);
|
|
18
|
+
const scan = (d) => {
|
|
19
|
+
const m = /https:\/\/(?!api\.)[a-z0-9]+(?:-[a-z0-9]+)+\.trycloudflare\.com/.exec(d.toString()); // not api.trycloudflare.com
|
|
20
|
+
if (m) {
|
|
21
|
+
clearTimeout(timer);
|
|
22
|
+
resolve(m[0]);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
child.stdout.on("data", scan);
|
|
26
|
+
child.stderr.on("data", scan);
|
|
27
|
+
child.on("exit", () => resolve(null));
|
|
28
|
+
});
|
|
29
|
+
if (!url) {
|
|
30
|
+
child.kill();
|
|
31
|
+
log(" couldn't open a tunnel — falling back to your local network");
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const close = () => child.kill();
|
|
35
|
+
process.once("exit", close);
|
|
36
|
+
return { url, close };
|
|
37
|
+
}
|
|
38
|
+
/** Your Tailscale address, if Tailscale is running — reachable by anyone on your tailnet, from anywhere. */
|
|
39
|
+
export function tailscaleIp() {
|
|
40
|
+
const r = spawnSync("tailscale", ["ip", "-4"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 3000 });
|
|
41
|
+
const ip = (r.stdout ?? "").trim().split("\n")[0];
|
|
42
|
+
return r.status === 0 && /^\d+\.\d+\.\d+\.\d+$/.test(ip) ? ip : null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Tailscale Funnel: a public https://<machine>.<tailnet>.ts.net/huddle address
|
|
46
|
+
* for this room's shared door — no server, works from any network, uses your
|
|
47
|
+
* Tailscale account. Mounted at /huddle so it never touches your other serves.
|
|
48
|
+
*/
|
|
49
|
+
export function openFunnel(port) {
|
|
50
|
+
const st = spawnSync("tailscale", ["status", "--json"], { encoding: "utf8", timeout: 5000 });
|
|
51
|
+
if (st.error)
|
|
52
|
+
return { ok: false, reason: "Tailscale isn't installed" };
|
|
53
|
+
let dns = "";
|
|
54
|
+
try {
|
|
55
|
+
const j = JSON.parse(st.stdout);
|
|
56
|
+
if (j.BackendState !== "Running")
|
|
57
|
+
return { ok: false, reason: "Tailscale is stopped — run `tailscale up` (or open the Tailscale app) and sign in" };
|
|
58
|
+
dns = String(j.Self?.DNSName ?? "").replace(/\.$/, "");
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return { ok: false, reason: "couldn't read Tailscale status" };
|
|
62
|
+
}
|
|
63
|
+
if (!dns)
|
|
64
|
+
return { ok: false, reason: "this machine has no Tailscale name yet" };
|
|
65
|
+
const r = spawnSync("tailscale", ["funnel", "--bg", "--yes", "--set-path", "/huddle", `http://127.0.0.1:${port}`], {
|
|
66
|
+
encoding: "utf8",
|
|
67
|
+
timeout: 20_000,
|
|
68
|
+
});
|
|
69
|
+
if (r.status !== 0) {
|
|
70
|
+
const err = `${r.stderr}${r.stdout}`.trim();
|
|
71
|
+
const link = /https:\/\/login\.tailscale\.com\S+/.exec(err)?.[0];
|
|
72
|
+
return {
|
|
73
|
+
ok: false,
|
|
74
|
+
reason: /not enabled|funnel.*(disabled|not allowed)|nodeAttr|policy/i.test(err)
|
|
75
|
+
? `Funnel isn't enabled for your tailnet yet — turn it on once${link ? ": " + link : " in the Tailscale admin (Access controls → funnel)"}`
|
|
76
|
+
: err.split("\n")[0] || "tailscale funnel failed",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const close = () => {
|
|
80
|
+
spawnSync("tailscale", ["funnel", "--set-path", "/huddle", "off"], { stdio: "ignore", timeout: 10_000 });
|
|
81
|
+
};
|
|
82
|
+
process.once("exit", close);
|
|
83
|
+
return { ok: true, url: `https://${dns}/huddle`, close };
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=tunnel.js.map
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Git sync: the code layer between people. Huddle never moves code itself —
|
|
2
|
+
// GitHub (or any git remote) does. Before a run we fast-forward to the latest
|
|
3
|
+
// shared main; after a run we push everything that merged, in one go.
|
|
4
|
+
//
|
|
5
|
+
// Pushing reaches outside this machine, so it's opt-in per repo: the first
|
|
6
|
+
// time a repo with a remote runs a goal, Huddle asks once and remembers it in
|
|
7
|
+
// .huddle/config.json.
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
function git(cwd, args, timeoutMs = 60_000) {
|
|
12
|
+
const r = spawnSync("git", args, { cwd, encoding: "utf8", timeout: timeoutMs, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
|
|
13
|
+
return { ok: r.status === 0, out: (r.stdout ?? "").trim(), err: (r.stderr ?? "").trim() };
|
|
14
|
+
}
|
|
15
|
+
const cfgPath = (root) => path.join(root, ".huddle", "config.json");
|
|
16
|
+
export function readRepoConfig(root) {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(fs.readFileSync(cfgPath(root), "utf8"));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function writeRepoConfig(root, c) {
|
|
25
|
+
fs.mkdirSync(path.dirname(cfgPath(root)), { recursive: true });
|
|
26
|
+
fs.writeFileSync(cfgPath(root), JSON.stringify({ ...readRepoConfig(root), ...c }, null, 2));
|
|
27
|
+
}
|
|
28
|
+
/** The remote this repo shares through, e.g. "origin → github.com/Preet37/Huddle". */
|
|
29
|
+
export function remoteInfo(root) {
|
|
30
|
+
const branch = git(root, ["symbolic-ref", "--short", "HEAD"]).out;
|
|
31
|
+
const upstream = git(root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
|
|
32
|
+
const name = upstream.ok ? upstream.out.split("/")[0] : "origin";
|
|
33
|
+
const url = git(root, ["remote", "get-url", name]);
|
|
34
|
+
if (!url.ok || !branch)
|
|
35
|
+
return null;
|
|
36
|
+
return { name, url: url.out, branch };
|
|
37
|
+
}
|
|
38
|
+
/** "git@github.com:Preet37/Huddle.git" → "github.com/Preet37/Huddle" — for matching clones. */
|
|
39
|
+
export function canonicalRemote(url) {
|
|
40
|
+
return url
|
|
41
|
+
.trim()
|
|
42
|
+
.replace(/^git@([^:]+):/, "$1/")
|
|
43
|
+
.replace(/^[a-z+]+:\/\/([^@/]+@)?/, "")
|
|
44
|
+
.replace(/\.git$/, "")
|
|
45
|
+
.replace(/\/+$/, "")
|
|
46
|
+
.toLowerCase();
|
|
47
|
+
}
|
|
48
|
+
/** Bring local main up to the shared main before agents branch off it. */
|
|
49
|
+
export function pullLatest(root) {
|
|
50
|
+
const r = remoteInfo(root);
|
|
51
|
+
if (!r)
|
|
52
|
+
return { ok: true, note: "no remote" };
|
|
53
|
+
const f = git(root, ["fetch", "--quiet", r.name, r.branch]);
|
|
54
|
+
if (!f.ok)
|
|
55
|
+
return { ok: false, note: `couldn't reach ${r.name}: ${f.err.split("\n")[0]}` };
|
|
56
|
+
const behind = Number(git(root, ["rev-list", "--count", `HEAD..${r.name}/${r.branch}`]).out || 0);
|
|
57
|
+
if (!behind)
|
|
58
|
+
return { ok: true, note: "up to date" };
|
|
59
|
+
const m = git(root, ["merge", "--ff-only", "--quiet", `${r.name}/${r.branch}`]);
|
|
60
|
+
if (!m.ok)
|
|
61
|
+
return { ok: false, note: `local ${r.branch} and ${r.name} have diverged — pull/rebase once by hand` };
|
|
62
|
+
return { ok: true, note: `pulled ${behind} new commit${behind > 1 ? "s" : ""}` };
|
|
63
|
+
}
|
|
64
|
+
/** Push everything that merged, in one go. Rebases once if someone pushed meanwhile. */
|
|
65
|
+
export function pushMerged(root) {
|
|
66
|
+
const r = remoteInfo(root);
|
|
67
|
+
if (!r)
|
|
68
|
+
return { ok: false, note: "no remote to push to" };
|
|
69
|
+
let p = git(root, ["push", "--quiet", r.name, `HEAD:${r.branch}`], 120_000);
|
|
70
|
+
if (!p.ok && /rejected|fetch first|non-fast-forward/i.test(p.err)) {
|
|
71
|
+
const rb = git(root, ["pull", "--rebase", "--quiet", r.name, r.branch], 120_000);
|
|
72
|
+
if (!rb.ok) {
|
|
73
|
+
git(root, ["rebase", "--abort"]);
|
|
74
|
+
return { ok: false, note: `${r.name} moved and the rebase conflicts — resolve and push by hand` };
|
|
75
|
+
}
|
|
76
|
+
p = git(root, ["push", "--quiet", r.name, `HEAD:${r.branch}`], 120_000);
|
|
77
|
+
}
|
|
78
|
+
if (!p.ok)
|
|
79
|
+
return { ok: false, note: p.err.split("\n").slice(-1)[0] || "push failed" };
|
|
80
|
+
return { ok: true, note: `${r.name}/${r.branch}` };
|
|
81
|
+
}
|
|
82
|
+
/** Push a task branch so the room's merger can fetch it (delegated work). */
|
|
83
|
+
export function pushBranch(root, branch) {
|
|
84
|
+
const r = remoteInfo(root);
|
|
85
|
+
if (!r)
|
|
86
|
+
return { ok: false, note: "no remote" };
|
|
87
|
+
const p = git(root, ["push", "--quiet", "--force-with-lease", r.name, `${branch}:${branch}`], 120_000);
|
|
88
|
+
return p.ok ? { ok: true, note: `${r.name}/${branch}` } : { ok: false, note: p.err.split("\n").slice(-1)[0] };
|
|
89
|
+
}
|
|
90
|
+
/** Fetch a teammate's task branch into a local branch of the same name. */
|
|
91
|
+
export function fetchBranch(root, branch) {
|
|
92
|
+
const r = remoteInfo(root);
|
|
93
|
+
if (!r)
|
|
94
|
+
return { ok: false, note: "no remote" };
|
|
95
|
+
const f = git(root, ["fetch", "--quiet", r.name, `+${branch}:${branch}`], 120_000);
|
|
96
|
+
return f.ok ? { ok: true, note: branch } : { ok: false, note: f.err.split("\n").slice(-1)[0] };
|
|
97
|
+
}
|
|
98
|
+
/** Clean up a task branch on the remote after it merged. Best effort. */
|
|
99
|
+
export function deleteRemoteBranch(root, branch) {
|
|
100
|
+
const r = remoteInfo(root);
|
|
101
|
+
if (r)
|
|
102
|
+
git(root, ["push", "--quiet", r.name, "--delete", branch]);
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=sync.js.map
|