symbiot 0.33.0 → 0.35.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/README.md +103 -108
- package/agents.mjs +308 -0
- package/core.mjs +53 -0
- package/drift.mjs +124 -0
- package/index.mjs +71 -422
- package/package.json +5 -2
- package/ui.mjs +22 -14
package/core.mjs
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Shared basics for every module: Symbiot's config and task files, the one
|
|
2
|
+
// shell helper (never throws, never hangs) and a repo's working-tree state.
|
|
3
|
+
import { execSync } from "node:child_process";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
|
|
7
|
+
|
|
8
|
+
// ---- config ---------------------------------------------------------------
|
|
9
|
+
const CONFIG_DIR = join(homedir(), ".config", "symbiot");
|
|
10
|
+
const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
11
|
+
function loadConfig() {
|
|
12
|
+
try { return JSON.parse(readFileSync(CONFIG_PATH, "utf8")); } catch { return {}; }
|
|
13
|
+
}
|
|
14
|
+
function saveConfig(cfg) {
|
|
15
|
+
try {
|
|
16
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
17
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n");
|
|
18
|
+
try { chmodSync(CONFIG_PATH, 0o600); } catch {}
|
|
19
|
+
return true;
|
|
20
|
+
} catch { return false; }
|
|
21
|
+
}
|
|
22
|
+
// ---- tasks: a persistent checklist (~/.config/symbiot/tasks.json) ---------
|
|
23
|
+
const TASKS_PATH = join(CONFIG_DIR, "tasks.json");
|
|
24
|
+
function loadTasks() { try { return JSON.parse(readFileSync(TASKS_PATH, "utf8")); } catch { return []; } }
|
|
25
|
+
function saveTasks(t) { try { mkdirSync(CONFIG_DIR, { recursive: true }); writeFileSync(TASKS_PATH, JSON.stringify(t, null, 2)); return true; } catch { return false; } }
|
|
26
|
+
|
|
27
|
+
// ---- shell + git ----------------------------------------------------------
|
|
28
|
+
function sh(cmd) {
|
|
29
|
+
try { return execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 32 * 1024 * 1024, timeout: 6000, killSignal: "SIGKILL" }); }
|
|
30
|
+
catch { return ""; } // timeout or error -> empty, never hang the scan
|
|
31
|
+
}
|
|
32
|
+
// "Is this command available?": command -v on posix, where on win.
|
|
33
|
+
function hasCmd(cmd) { try { return !!sh(process.platform === "win32" ? `where ${cmd}` : `command -v ${cmd}`).trim(); } catch { return false; } }
|
|
34
|
+
// Working-tree state, incl. detecting a stale/old checkout (not new work).
|
|
35
|
+
function repoState(repoPath) {
|
|
36
|
+
const branch = sh(`git -C ${JSON.stringify(repoPath)} rev-parse --abbrev-ref HEAD 2>/dev/null`).trim();
|
|
37
|
+
const porcelain = sh(`git -C ${JSON.stringify(repoPath)} status --porcelain 2>/dev/null`).split("\n").filter(Boolean);
|
|
38
|
+
let del = 0, mod = 0, add = 0;
|
|
39
|
+
for (const l of porcelain) { const x = l.slice(0, 2); if (/\?\?/.test(x)) add++; else if (x.includes("D")) del++; else if (x.includes("A")) add++; else mod++; }
|
|
40
|
+
const dirty = porcelain.length;
|
|
41
|
+
let stale = false, staleBy = 0;
|
|
42
|
+
if (dirty) {
|
|
43
|
+
for (const k of [3, 5, 10, 20, 40, 80, 160, 320]) {
|
|
44
|
+
if (!sh(`git -C ${JSON.stringify(repoPath)} rev-parse HEAD~${k} 2>/dev/null`).trim()) break;
|
|
45
|
+
if (sh(`git -C ${JSON.stringify(repoPath)} diff --quiet HEAD~${k} 2>/dev/null && echo EQ`).trim() === "EQ") { stale = true; staleBy = k; break; }
|
|
46
|
+
}
|
|
47
|
+
if (!stale && del >= 20 && del > mod && add === 0) stale = true; // mostly deletions = an old snapshot
|
|
48
|
+
}
|
|
49
|
+
const behind = Number(sh(`git -C ${JSON.stringify(repoPath)} rev-list --count HEAD..@{u} 2>/dev/null`).trim()) || 0;
|
|
50
|
+
return { branch, dirty, del, mod, add, stale, staleBy, behind };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export { CONFIG_DIR, CONFIG_PATH, loadConfig, saveConfig, loadTasks, saveTasks, sh, hasCmd, repoState };
|
package/drift.mjs
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Drift facts for one repo: what's out of sync, stuck or at risk, from git
|
|
2
|
+
// alone (plus gh for --ci and the user's own deploy commands).
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { readFileSync, statSync } from "node:fs";
|
|
5
|
+
import { CONFIG_DIR, sh, hasCmd, repoState } from "./core.mjs";
|
|
6
|
+
|
|
7
|
+
// ---- drift: what's out of sync / stuck / at risk (deterministic git facts) -
|
|
8
|
+
function gitDefaultBranch(repo) {
|
|
9
|
+
const d = sh(`git -C ${JSON.stringify(repo)} symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null`).trim();
|
|
10
|
+
if (d) return d.replace(/^origin\//, "");
|
|
11
|
+
for (const b of ["main", "master", "develop"]) if (sh(`git -C ${JSON.stringify(repo)} rev-parse --verify -q refs/remotes/origin/${b} 2>/dev/null`).trim()) return b;
|
|
12
|
+
return sh(`git -C ${JSON.stringify(repo)} rev-parse --abbrev-ref HEAD 2>/dev/null`).trim() || "main";
|
|
13
|
+
}
|
|
14
|
+
function fetchAgeDays(repo) {
|
|
15
|
+
const gd = sh(`git -C ${JSON.stringify(repo)} rev-parse --git-common-dir 2>/dev/null`).trim();
|
|
16
|
+
if (!gd) return null;
|
|
17
|
+
const p = gd.startsWith("/") ? join(gd, "FETCH_HEAD") : join(repo, gd, "FETCH_HEAD");
|
|
18
|
+
try { return Math.floor((Date.now() - statSync(p).mtimeMs) / 86400000); } catch { return null; }
|
|
19
|
+
}
|
|
20
|
+
function worktreeCount(repo) { return sh(`git -C ${JSON.stringify(repo)} worktree list 2>/dev/null`).split("\n").filter(Boolean).length; }
|
|
21
|
+
// Deployed-sha commands are read ONLY from the user's own config (never from a
|
|
22
|
+
// repo file, which could be attacker-controlled): ~/.config/symbiot/deploys.json
|
|
23
|
+
function loadDeploys() { try { return JSON.parse(readFileSync(join(CONFIG_DIR, "deploys.json"), "utf8")); } catch { return {}; } }
|
|
24
|
+
function ciState(repo, def) {
|
|
25
|
+
if (!hasCmd("gh")) return null;
|
|
26
|
+
if (!/github\.com/i.test(sh(`git -C ${JSON.stringify(repo)} remote get-url origin 2>/dev/null`))) return null;
|
|
27
|
+
const j = sh(`cd ${JSON.stringify(repo)} && gh run list --branch ${def} --limit 1 --json databaseId,conclusion,status 2>/dev/null`).trim();
|
|
28
|
+
if (!j) return null;
|
|
29
|
+
try {
|
|
30
|
+
const a = JSON.parse(j); if (!a.length) return null; const r = a[0];
|
|
31
|
+
if (r.status && r.status !== "completed") return { level: "info", text: `CI on ${def}: ${r.status}` };
|
|
32
|
+
if (r.conclusion === "success") return { level: "info", text: `CI on ${def}: passing` };
|
|
33
|
+
if (r.conclusion === "failure") {
|
|
34
|
+
// Deterministic "not running" signal: every job has 0 steps (never started).
|
|
35
|
+
let notRun = false, reason = "";
|
|
36
|
+
try {
|
|
37
|
+
const jobs = (JSON.parse(sh(`cd ${JSON.stringify(repo)} && gh run view ${r.databaseId} --json jobs 2>/dev/null`)).jobs) || [];
|
|
38
|
+
notRun = jobs.length > 0 && jobs.every((x) => ((x.steps || []).length === 0));
|
|
39
|
+
if (notRun && jobs[0] && jobs[0].databaseId) {
|
|
40
|
+
// Only on the not-running path: fetch the one job's annotation to quote
|
|
41
|
+
// GitHub's exact reason (billing / spending limit / runner / disabled).
|
|
42
|
+
const cru = sh(`cd ${JSON.stringify(repo)} && gh api repos/{owner}/{repo}/actions/jobs/${jobs[0].databaseId} --jq .check_run_url 2>/dev/null`).trim();
|
|
43
|
+
const crid = (cru.match(/check-runs\/(\d+)/) || [])[1];
|
|
44
|
+
if (crid) { const m = sh(`cd ${JSON.stringify(repo)} && gh api repos/{owner}/{repo}/check-runs/${crid}/annotations --jq '.[0].message' 2>/dev/null`).trim(); if (m) reason = m.slice(0, 240); }
|
|
45
|
+
}
|
|
46
|
+
} catch {}
|
|
47
|
+
if (notRun) return { level: "warn", text: reason ? `CI is NOT running on ${def}: "${reason}"` : `CI is NOT running on ${def} — jobs never started (reason unavailable; usually a billing/spending-limit stop), not failing tests` };
|
|
48
|
+
return { level: "warn", text: `CI's last run on ${def} failed (a real failure — jobs ran)` };
|
|
49
|
+
}
|
|
50
|
+
if (r.conclusion) return { level: "warn", text: `CI on ${def}: ${r.conclusion}` };
|
|
51
|
+
return null;
|
|
52
|
+
} catch { return null; }
|
|
53
|
+
}
|
|
54
|
+
// For merges that aren't on the default branch, tell "likely never landed"
|
|
55
|
+
// (added files missing from default) from "merged another way" (all present).
|
|
56
|
+
function mergedOffDefault(p, defRef) {
|
|
57
|
+
const raw = sh(`git -C ${JSON.stringify(p)} log --merges --all --not ${defRef} --format='%H|%s' 2>/dev/null`)
|
|
58
|
+
.split("\n").filter((l) => /merge pull request/i.test(l));
|
|
59
|
+
if (!raw.length) return [];
|
|
60
|
+
const defFiles = new Set(sh(`git -C ${JSON.stringify(p)} ls-tree -r --name-only ${defRef} 2>/dev/null`).split("\n").filter(Boolean));
|
|
61
|
+
const hits = [];
|
|
62
|
+
for (const line of raw.slice(0, 12)) {
|
|
63
|
+
const [sha, subj] = line.split("|");
|
|
64
|
+
const prNum = (subj.match(/#(\d+)/) || [])[1] || "?";
|
|
65
|
+
const parents = sh(`git -C ${JSON.stringify(p)} rev-list --parents -n1 ${sha} 2>/dev/null`).trim().split(/\s+/);
|
|
66
|
+
const head = parents[2]; // 2nd parent = the merged branch head
|
|
67
|
+
let added = 0, missing = 0;
|
|
68
|
+
if (head) {
|
|
69
|
+
const mb = sh(`git -C ${JSON.stringify(p)} merge-base ${head} ${defRef} 2>/dev/null`).trim();
|
|
70
|
+
const files = sh(`git -C ${JSON.stringify(p)} diff --name-status ${mb || defRef} ${head} 2>/dev/null`)
|
|
71
|
+
.split("\n").map((l) => l.trim()).filter((l) => /^A\b|^A\t/.test(l)).map((l) => l.split(/\s+/).pop());
|
|
72
|
+
added = files.length;
|
|
73
|
+
for (const f of files) if (f && !defFiles.has(f)) missing++;
|
|
74
|
+
}
|
|
75
|
+
hits.push({ prNum, sha: sha.slice(0, 9), added, missing });
|
|
76
|
+
}
|
|
77
|
+
return hits;
|
|
78
|
+
}
|
|
79
|
+
// Drift facts for ONE repo. Compares against origin/<default> when a remote
|
|
80
|
+
// exists, else the local default branch (so it also works on local-only repos).
|
|
81
|
+
function driftRepo(p, opts = {}) {
|
|
82
|
+
const name = p.split("/").pop();
|
|
83
|
+
const def = gitDefaultBranch(p), st = repoState(p), flags = [];
|
|
84
|
+
const originDef = sh(`git -C ${JSON.stringify(p)} rev-parse --verify -q refs/remotes/origin/${def} 2>/dev/null`).trim();
|
|
85
|
+
const localDef = sh(`git -C ${JSON.stringify(p)} rev-parse --verify -q refs/heads/${def} 2>/dev/null`).trim();
|
|
86
|
+
const defRef = originDef ? `origin/${def}` : (localDef ? def : "");
|
|
87
|
+
const cmd = opts.deploys && (opts.deploys[p] || opts.deploys[name]);
|
|
88
|
+
// Fetch only when asked, or for a deploy-configured repo (comparing a live
|
|
89
|
+
// deployed sha against a stale origin gives a confidently wrong answer).
|
|
90
|
+
if (originDef && (opts.fetch || cmd)) sh(`git -C ${JSON.stringify(p)} fetch -q origin 2>/dev/null`);
|
|
91
|
+
const fa = fetchAgeDays(p);
|
|
92
|
+
const stale = fa != null && fa > 1 ? ` (as of ${fa}d ago)` : "";
|
|
93
|
+
|
|
94
|
+
if (st.stale) flags.push({ level: "warn", text: `checkout is stale — working tree ≈ ${st.staleBy ? "HEAD~" + st.staleBy : "an older commit"}, not new work` });
|
|
95
|
+
else if (st.dirty) flags.push({ level: "info", text: `${st.dirty} uncommitted (${st.mod} mod / ${st.del} del / ${st.add} new)` });
|
|
96
|
+
if (st.behind) flags.push({ level: "warn", text: `${st.behind} behind upstream on ${st.branch}${stale}` });
|
|
97
|
+
const wc = worktreeCount(p); if (wc > 1) flags.push({ level: "info", text: `${wc} checkouts of this repo` });
|
|
98
|
+
if (originDef) {
|
|
99
|
+
const unmerged = sh(`git -C ${JSON.stringify(p)} branch -r --no-merged origin/${def} 2>/dev/null`).split("\n").map((s) => s.trim()).filter((b) => b && !b.startsWith("origin/HEAD"));
|
|
100
|
+
if (unmerged.length) flags.push({ level: "info", text: `${unmerged.length} branch(es) with work not on ${def}` });
|
|
101
|
+
}
|
|
102
|
+
if (defRef) {
|
|
103
|
+
const hits = mergedOffDefault(p, defRef);
|
|
104
|
+
const gone = hits.filter((h) => h.added > 0 && h.missing > 0);
|
|
105
|
+
const other = hits.length - gone.length;
|
|
106
|
+
if (gone.length) flags.push({ level: "warn", text: `${gone.length} PR(s) merged off ${def} with added files MISSING from ${def} — likely never landed`, evidence: gone.slice(0, 5).map((h) => `#${h.prNum} (${h.missing}/${h.added} files missing)`).join(" | ") });
|
|
107
|
+
if (other > 0) flags.push({ level: "info", text: `${other} other PR merge(s) off ${def} (files present — probably re-done/squashed)` });
|
|
108
|
+
}
|
|
109
|
+
if (cmd && defRef) {
|
|
110
|
+
const sha = sh(cmd).trim().split(/\s+/)[0];
|
|
111
|
+
if (sha) {
|
|
112
|
+
const onDef = sh(`git -C ${JSON.stringify(p)} merge-base --is-ancestor ${sha} ${defRef} 2>/dev/null && echo Y`).trim() === "Y";
|
|
113
|
+
const behind = Number(sh(`git -C ${JSON.stringify(p)} rev-list --count ${sha}..${defRef} 2>/dev/null`).trim()) || 0;
|
|
114
|
+
const ahead = Number(sh(`git -C ${JSON.stringify(p)} rev-list --count ${defRef}..${sha} 2>/dev/null`).trim()) || 0;
|
|
115
|
+
if (!onDef) flags.push({ level: "warn", text: `production runs code NOT on ${def}${ahead ? ` (${ahead} commits ahead of it)` : ""}`, evidence: sha.slice(0, 9) });
|
|
116
|
+
else if (behind) flags.push({ level: "warn", text: `production is ${behind} behind ${def}`, evidence: sha.slice(0, 9) });
|
|
117
|
+
else flags.push({ level: "info", text: `production in sync with ${def}`, evidence: sha.slice(0, 9) });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (opts.ci) { const ci = ciState(p, def); if (ci) flags.push(ci); }
|
|
121
|
+
return { name, path: p, def, flags, fetchAgeDays: fa };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export { gitDefaultBranch, loadDeploys, driftRepo };
|