@zuzuucodes/cli 1.2.3 → 1.3.1
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/bin/zuzuu.mjs +8 -2
- package/package.json +1 -1
- package/web-app/dist/index.js +19 -0
- package/web-app/dist/instance-file.js +62 -0
- package/web-app/dist/server.js +65 -3
- package/web-app/dist/sessions.js +42 -8
- package/web-app/dist/zuzuu-api.js +24 -3
- package/web-app/web-dist/assets/{DiffTab-Clz0uEu_.js → DiffTab-CihRJjzf.js} +1 -1
- package/web-app/web-dist/assets/{MonacoFile-DguRe1Rt.js → MonacoFile-DJvpGyW2.js} +1 -1
- package/web-app/web-dist/assets/{cssMode-C4OLzNbC.js → cssMode-R1Bks9TO.js} +1 -1
- package/web-app/web-dist/assets/{dist-DYKlGApw.js → dist-jCnX6g-O.js} +1 -1
- package/web-app/web-dist/assets/{htmlMode-D1O2jo0s.js → htmlMode-Csqnn3yv.js} +1 -1
- package/web-app/web-dist/assets/index-D_MPtALn.css +2 -0
- package/web-app/web-dist/assets/index-Ye54YyTn.js +267 -0
- package/web-app/web-dist/assets/{jsonMode-D8YStjhJ.js → jsonMode-DRBg9jwi.js} +1 -1
- package/web-app/web-dist/assets/{monaco-setup-BBNGrQzm.js → monaco-setup-Dszx738Y.js} +3 -3
- package/web-app/web-dist/assets/{tsMode-B8P6eQAV.js → tsMode-9YOHYiVQ.js} +1 -1
- package/web-app/web-dist/index.html +2 -2
- package/zuzuu/commands/doctor.mjs +10 -0
- package/zuzuu/commands/hook.mjs +24 -1
- package/zuzuu/commands/session.mjs +103 -0
- package/zuzuu/commands/status.mjs +10 -3
- package/zuzuu/commands/web.mjs +113 -8
- package/zuzuu/live/live-store.mjs +7 -0
- package/zuzuu/session-git.mjs +392 -0
- package/web-app/web-dist/assets/index-Cfwhe1gB.js +0 -270
- package/web-app/web-dist/assets/index-RHYMLHDZ.css +0 -2
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// `zuzuu status` — detected hosts + recorded sessions (the git-native index).
|
|
2
2
|
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
|
+
import { dirname } from 'node:path';
|
|
4
5
|
import { detected } from '../capture/adapters/registry.mjs';
|
|
6
|
+
import { sessionStatus } from '../session-git.mjs';
|
|
5
7
|
import { readIndex, paths } from '../store.mjs';
|
|
6
8
|
import { FACULTIES } from '../faculty/contract.mjs';
|
|
7
9
|
import { listProposals } from '../faculty/proposal.mjs';
|
|
@@ -10,8 +12,9 @@ import { detectDrift } from './doctor.mjs';
|
|
|
10
12
|
|
|
11
13
|
const fmtDur = (ms) => (ms < 60_000 ? `${(ms / 1000).toFixed(0)}s` : `${(ms / 60_000).toFixed(1)}m`);
|
|
12
14
|
|
|
13
|
-
/** Pure: structured status for a faculty home (the zuzuu-web /status source). Fail-soft per field.
|
|
14
|
-
|
|
15
|
+
/** Pure: structured status for a faculty home (the zuzuu-web /status source). Fail-soft per field.
|
|
16
|
+
* `session` is injectable (like hosts) for hermetic tests; default = the repo above the home. */
|
|
17
|
+
export function statusData(agentDir, { hosts = detected().map((a) => ({ name: a.name })), session } = {}) {
|
|
15
18
|
let active = null, drift = { dirty: false, items: [] };
|
|
16
19
|
const pending = {};
|
|
17
20
|
try { active = activeGenerationFn(agentDir); } catch { active = null; }
|
|
@@ -23,7 +26,11 @@ export function statusData(agentDir, { hosts = detected().map((a) => ({ name: a.
|
|
|
23
26
|
const items = Array.isArray(d?.drifted) ? d.drifted : [];
|
|
24
27
|
drift = { dirty: items.length > 0, items };
|
|
25
28
|
} catch { /* fail-soft */ }
|
|
26
|
-
|
|
29
|
+
let sess = session;
|
|
30
|
+
if (sess === undefined) {
|
|
31
|
+
try { sess = sessionStatus(dirname(agentDir)); } catch { sess = null; } // sessionStatus never throws — belt + braces
|
|
32
|
+
}
|
|
33
|
+
return { home: existsSync(agentDir), activeGeneration: active, pending, drift, hosts, session: sess };
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
/**
|
package/zuzuu/commands/web.mjs
CHANGED
|
@@ -11,14 +11,27 @@
|
|
|
11
11
|
// the workbench, never the CLI. `--omit=optional` installs skip the deps; this
|
|
12
12
|
// command then explains how to repair.
|
|
13
13
|
//
|
|
14
|
+
// Singleton-per-workspace (2026-06-12): the daemon records itself in
|
|
15
|
+
// ~/.webcode/instances/<sha256(realpath-root).slice(0,16)>.json after listen
|
|
16
|
+
// and removes it on clean shutdown (daemon src/instance-file.ts — the path
|
|
17
|
+
// scheme here MUST stay in sync with it). Before spawning we check that file:
|
|
18
|
+
// a live instance (pid alive + HTTP listener answering) is REUSED — same port,
|
|
19
|
+
// same token, old browser tabs keep working; a stale file is deleted and we
|
|
20
|
+
// spawn fresh. `--stop` / `--status` manage the running instance.
|
|
21
|
+
// (The file carries the auth token: 0600, user's own machine, and the token
|
|
22
|
+
// already appears in the daemon's stdout — acceptable.)
|
|
23
|
+
//
|
|
14
24
|
// Resolution order:
|
|
15
25
|
// 1. the bundled web-app/dist next to this package (installed OR repo after build:web)
|
|
16
26
|
// 2. the nested dev project's built daemon (web/packages/daemon, repo checkout)
|
|
17
27
|
// 3. a standalone `zuzuu-web` on PATH (legacy/manual installs)
|
|
18
28
|
// 4. none → repair hint (reinstall, or `npm run build:web` in a checkout)
|
|
19
29
|
|
|
20
|
-
import { existsSync } from 'node:fs';
|
|
30
|
+
import { existsSync, readFileSync, realpathSync, unlinkSync } from 'node:fs';
|
|
21
31
|
import { resolve, dirname, join } from 'node:path';
|
|
32
|
+
import { homedir } from 'node:os';
|
|
33
|
+
import { createHash } from 'node:crypto';
|
|
34
|
+
import http from 'node:http';
|
|
22
35
|
import { spawn } from 'node:child_process';
|
|
23
36
|
import { spawnSync } from 'node:child_process';
|
|
24
37
|
import { fileURLToPath } from 'node:url';
|
|
@@ -45,25 +58,108 @@ const realLaunch = ({ cwd, entryScript }) => {
|
|
|
45
58
|
if (entryScript) spawn(process.execPath, [entryScript, cwd], { detached: true, stdio: 'ignore' }).unref();
|
|
46
59
|
else spawn('zuzuu-web', [cwd], { detached: true, stdio: 'ignore' }).unref();
|
|
47
60
|
};
|
|
61
|
+
// Same scheme as the daemon's instance-file.ts: sha256 of the realpath'd root.
|
|
62
|
+
const realInstancePathFor = (dir) => {
|
|
63
|
+
let real = dir;
|
|
64
|
+
try { real = realpathSync(dir); } catch { /* missing dir → daemon will refuse anyway */ }
|
|
65
|
+
const id = createHash('sha256').update(real).digest('hex').slice(0, 16);
|
|
66
|
+
return join(homedir(), '.webcode', 'instances', `${id}.json`);
|
|
67
|
+
};
|
|
68
|
+
const realReadInstance = (file) => {
|
|
69
|
+
try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return null; }
|
|
70
|
+
};
|
|
71
|
+
const realRemoveFile = (file) => { try { unlinkSync(file); } catch { /* gone already */ } };
|
|
72
|
+
const realPidAlive = (pid) => {
|
|
73
|
+
try { process.kill(pid, 0); return true; }
|
|
74
|
+
catch (err) { return err?.code === 'EPERM'; } // EPERM = alive, just not ours
|
|
75
|
+
};
|
|
76
|
+
const realKillPid = (pid, signal) => { try { process.kill(pid, signal); } catch { /* gone */ } };
|
|
77
|
+
// Connectivity probe only — ANY HTTP answer (even 401) means a listener is there.
|
|
78
|
+
const realProbe = (port) => new Promise((done) => {
|
|
79
|
+
const req = http.get({ host: '127.0.0.1', port, path: '/api/health', timeout: 1000 }, (res) => {
|
|
80
|
+
res.resume();
|
|
81
|
+
done(true);
|
|
82
|
+
});
|
|
83
|
+
req.on('timeout', () => { req.destroy(); done(false); });
|
|
84
|
+
req.on('error', () => done(false));
|
|
85
|
+
});
|
|
86
|
+
const realOpenBrowser = (url) => { // fail-soft: a missing opener never breaks the command
|
|
87
|
+
try {
|
|
88
|
+
const cmd = process.platform === 'darwin' ? 'open'
|
|
89
|
+
: process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
90
|
+
spawn(cmd, [url], { stdio: 'ignore', detached: true, shell: process.platform === 'win32' }).unref();
|
|
91
|
+
} catch { /* ignore */ }
|
|
92
|
+
};
|
|
93
|
+
const realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
94
|
+
|
|
95
|
+
const urlOf = (inst) => `http://127.0.0.1:${inst.port}/?token=${inst.token}`;
|
|
48
96
|
|
|
49
97
|
/**
|
|
50
|
-
* `zuzuu web [dir]`
|
|
51
|
-
* Launch the visual workbench for the given directory (default: cwd)
|
|
98
|
+
* `zuzuu web [dir] [--stop|--status]`
|
|
99
|
+
* Launch the visual workbench for the given directory (default: cwd) —
|
|
100
|
+
* reusing an already-running daemon for that workspace when there is one.
|
|
52
101
|
* Bundled-first; never installs anything — the workbench ships in this package.
|
|
53
102
|
*/
|
|
54
|
-
export function web(args = {}, deps = {}) {
|
|
103
|
+
export async function web(args = {}, deps = {}) {
|
|
55
104
|
const d = {
|
|
56
105
|
resolveBundled: realResolveBundled,
|
|
57
106
|
detectPath: realDetectPath,
|
|
58
107
|
launch: realLaunch,
|
|
108
|
+
instancePathFor: realInstancePathFor,
|
|
109
|
+
readInstance: realReadInstance,
|
|
110
|
+
removeFile: realRemoveFile,
|
|
111
|
+
pidAlive: realPidAlive,
|
|
112
|
+
killPid: realKillPid,
|
|
113
|
+
probe: realProbe,
|
|
114
|
+
openBrowser: realOpenBrowser,
|
|
115
|
+
sleep: realSleep,
|
|
59
116
|
log: (...m) => console.log(...m),
|
|
60
117
|
...deps,
|
|
61
118
|
};
|
|
62
119
|
|
|
63
|
-
// 1. resolve the target directory
|
|
120
|
+
// 1. resolve the target directory + its instance state
|
|
64
121
|
const dir = args._?.[0] ? resolve(String(args._[0])) : process.cwd();
|
|
122
|
+
const instanceFile = d.instancePathFor(dir);
|
|
123
|
+
const inst = d.readInstance(instanceFile);
|
|
124
|
+
const isAlive = async (i) =>
|
|
125
|
+
!!(i && Number.isInteger(i.pid) && d.pidAlive(i.pid) && await d.probe(i.port));
|
|
126
|
+
|
|
127
|
+
// --stop: terminate the running daemon for this workspace
|
|
128
|
+
if (args.stop) {
|
|
129
|
+
if (!inst) { d.log(`no workbench running for ${dir}`); return; }
|
|
130
|
+
if (d.pidAlive(inst.pid)) {
|
|
131
|
+
d.killPid(inst.pid, 'SIGTERM');
|
|
132
|
+
for (let i = 0; i < 15 && d.pidAlive(inst.pid); i++) await d.sleep(200); // ~3s grace
|
|
133
|
+
if (d.pidAlive(inst.pid)) d.log(`workbench (pid ${inst.pid}) hasn't exited yet — still shutting down.`);
|
|
134
|
+
else d.log(`stopped workbench for ${dir} (pid ${inst.pid})`);
|
|
135
|
+
} else {
|
|
136
|
+
d.log(`workbench for ${dir} was not running — cleaned up stale state.`);
|
|
137
|
+
}
|
|
138
|
+
d.removeFile(instanceFile); // daemon removes it itself on SIGTERM; this is belt-and-braces
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
65
141
|
|
|
66
|
-
//
|
|
142
|
+
// --status: report without side effects
|
|
143
|
+
if (args.status) {
|
|
144
|
+
if (await isAlive(inst)) {
|
|
145
|
+
d.log(`workbench running for ${dir}`);
|
|
146
|
+
d.log(` pid ${inst.pid} → ${urlOf(inst)}`);
|
|
147
|
+
} else {
|
|
148
|
+
d.log(`no workbench running for ${dir}`);
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 2. reuse a live instance: same port + token, old tabs stay valid
|
|
154
|
+
if (await isAlive(inst)) {
|
|
155
|
+
d.log(`workbench already running for ${dir}`);
|
|
156
|
+
d.log(` → ${urlOf(inst)}`);
|
|
157
|
+
d.openBrowser(urlOf(inst));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (inst) d.removeFile(instanceFile); // stale (dead pid / no listener) → spawn fresh
|
|
161
|
+
|
|
162
|
+
// 3. find the workbench: bundled → PATH → repair hint
|
|
67
163
|
const entryScript = d.resolveBundled();
|
|
68
164
|
if (!entryScript && !d.detectPath()) {
|
|
69
165
|
d.log('the workbench is not available in this install.');
|
|
@@ -72,8 +168,17 @@ export function web(args = {}, deps = {}) {
|
|
|
72
168
|
return;
|
|
73
169
|
}
|
|
74
170
|
|
|
75
|
-
//
|
|
171
|
+
// 4. launch — the daemon opens the browser itself on fresh boot, so we only
|
|
172
|
+
// wait for its instance file to surface the URL here (don't double-open).
|
|
76
173
|
d.log(`zuzuu web → launching visual workbench in ${dir} …`);
|
|
77
|
-
d.log(' it will open your browser and print its URL.');
|
|
78
174
|
d.launch({ cwd: dir, entryScript });
|
|
175
|
+
for (let i = 0; i < 30; i++) { // ~6s
|
|
176
|
+
await d.sleep(200);
|
|
177
|
+
const fresh = d.readInstance(instanceFile);
|
|
178
|
+
if (fresh && d.pidAlive(fresh.pid)) {
|
|
179
|
+
d.log(` → ${urlOf(fresh)}`);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
d.log(' it will open your browser and print its URL.');
|
|
79
184
|
}
|
|
@@ -50,6 +50,13 @@ export function touchLive({ id, host, transcriptPath, now }, cwd = process.cwd()
|
|
|
50
50
|
return write({ ...existing, lastSeen: now, transcriptPath: transcriptPath ?? existing.transcriptPath }, cwd);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/** Merge a patch into an existing live record (no-op when absent). */
|
|
54
|
+
export function updateLive(id, patch, cwd = process.cwd()) {
|
|
55
|
+
const existing = read(id, cwd);
|
|
56
|
+
if (!existing) return null;
|
|
57
|
+
return write({ ...existing, ...patch }, cwd);
|
|
58
|
+
}
|
|
59
|
+
|
|
53
60
|
/** Remove a live record (its lifecycle has reached a terminal state). */
|
|
54
61
|
export function closeLive(id, cwd = process.cwd()) {
|
|
55
62
|
try {
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
// zuzuu/session-git.mjs — invisible session-git: one agent session = one branch.
|
|
2
|
+
//
|
|
3
|
+
// OPEN → create `zz/session-<shortid>` (a branch, never a worktree)
|
|
4
|
+
// TURN → checkpoint commit ON the session branch
|
|
5
|
+
// END → squash-merge to main as ONE commit `session: <title>`, delete branch
|
|
6
|
+
//
|
|
7
|
+
// THE most safety-critical module in the codebase: it runs git mutations inside
|
|
8
|
+
// USERS' repos, triggered from fail-open lifecycle hooks. Therefore:
|
|
9
|
+
// - every exported op is try-wrapped and returns { ok:false, reason } — NEVER throws
|
|
10
|
+
// - all git goes through spawnSync('git', [args], {cwd}) — no shell strings
|
|
11
|
+
// - non-repo / bare / detached HEAD / merge-rebase-in-progress / unborn HEAD → no-op
|
|
12
|
+
// - never push, never touch remotes, never auto-resolve conflicts (conflict →
|
|
13
|
+
// abort the squash, restore the branch, leave the repo exactly as before)
|
|
14
|
+
// - single-working-branch invariant: at most ONE `zz/session-*` branch; a
|
|
15
|
+
// leftover (crashed session) BLOCKS new session branches until continued,
|
|
16
|
+
// merged, or discarded — the next-session prompt is the recovery path.
|
|
17
|
+
// - secrets policy: checkpoints NEVER stage secret material (the same family
|
|
18
|
+
// the seeded no-secret-reads guardrail denies: .env/.env.* at any depth,
|
|
19
|
+
// *.pem, *.key, id_rsa*) — excluded files stay untracked in the worktree
|
|
20
|
+
// and are reported as `excludedSecrets`.
|
|
21
|
+
// - checkpoint history is never destroyed silently: an empty squash that
|
|
22
|
+
// still has checkpoints KEEPS the branch (explicit discard is the only
|
|
23
|
+
// way exploration history is dropped).
|
|
24
|
+
|
|
25
|
+
import { spawnSync } from 'node:child_process';
|
|
26
|
+
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
27
|
+
import { join, isAbsolute, resolve } from 'node:path';
|
|
28
|
+
|
|
29
|
+
const PREFIX = 'zz/session-';
|
|
30
|
+
|
|
31
|
+
/** One git call — argv array only (no shell), never throws. */
|
|
32
|
+
function git(args, cwd, input) {
|
|
33
|
+
try {
|
|
34
|
+
const r = spawnSync('git', args, { cwd, encoding: 'utf8', input });
|
|
35
|
+
return { ok: r.status === 0 && !r.error, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() };
|
|
36
|
+
} catch (e) {
|
|
37
|
+
return { ok: false, out: '', err: String(e) };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function gitDir(cwd) {
|
|
42
|
+
const r = git(['rev-parse', '--git-dir'], cwd);
|
|
43
|
+
if (!r.ok || !r.out) return null;
|
|
44
|
+
return isAbsolute(r.out) ? r.out : resolve(cwd, r.out);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Current branch name, or null when detached / not a repo. */
|
|
48
|
+
function currentBranch(cwd) {
|
|
49
|
+
const r = git(['symbolic-ref', '--short', '-q', 'HEAD'], cwd);
|
|
50
|
+
return r.ok && r.out ? r.out : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const branchExists = (cwd, name) => git(['rev-parse', '-q', '--verify', `refs/heads/${name}`], cwd).ok;
|
|
54
|
+
const isDirty = (cwd) => !!git(['status', '--porcelain'], cwd).out;
|
|
55
|
+
|
|
56
|
+
/** Why git mutations are unsafe right now, or null when clear. */
|
|
57
|
+
function unsafeReason(cwd) {
|
|
58
|
+
const inside = git(['rev-parse', '--is-inside-work-tree'], cwd);
|
|
59
|
+
if (!inside.ok || inside.out !== 'true') return 'not-a-git-repo';
|
|
60
|
+
if (!git(['rev-parse', '-q', '--verify', 'HEAD'], cwd).ok) return 'no-commits';
|
|
61
|
+
if (!currentBranch(cwd)) return 'detached-head';
|
|
62
|
+
const gd = gitDir(cwd);
|
|
63
|
+
if (gd) {
|
|
64
|
+
// An in-progress merge/rebase/cherry-pick/bisect belongs to the USER — a
|
|
65
|
+
// checkpoint commit here would conclude their operation. Hands off.
|
|
66
|
+
for (const f of ['MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'BISECT_LOG', 'rebase-merge', 'rebase-apply']) {
|
|
67
|
+
if (existsSync(join(gd, f))) return 'operation-in-progress';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Project opt-out: `.zuzuu/agent.json` carrying `"sessionGit": false`. */
|
|
74
|
+
function optedOut(cwd) {
|
|
75
|
+
try {
|
|
76
|
+
const root = git(['rev-parse', '--show-toplevel'], cwd);
|
|
77
|
+
if (!root.ok || !root.out) return false;
|
|
78
|
+
const f = join(root.out, '.zuzuu', 'agent.json');
|
|
79
|
+
if (!existsSync(f)) return false;
|
|
80
|
+
return JSON.parse(readFileSync(f, 'utf8')).sessionGit === false;
|
|
81
|
+
} catch {
|
|
82
|
+
return false; // an unreadable manifest never *enables* danger — but opt-out is explicit
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function disabledReason(cwd) {
|
|
87
|
+
const r = unsafeReason(cwd);
|
|
88
|
+
if (r) return r;
|
|
89
|
+
if (optedOut(cwd)) return 'opted-out';
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** True when session-git may act here: a usable git repo, not opted out. */
|
|
94
|
+
export function sessionGitEnabled(cwd) {
|
|
95
|
+
try {
|
|
96
|
+
return !disabledReason(cwd);
|
|
97
|
+
} catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** `zz/session-` + first 8 of the id, sanitized to [a-z0-9]. */
|
|
103
|
+
export function sessionBranchName(sessionId) {
|
|
104
|
+
const short = String(sessionId ?? '').toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 8);
|
|
105
|
+
return PREFIX + (short || 'unknown');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** All `zz/session-*` branches (there should be at most one — the invariant). */
|
|
109
|
+
export function listSessionBranches(cwd) {
|
|
110
|
+
try {
|
|
111
|
+
const r = git(['for-each-ref', '--format=%(refname:short)', `refs/heads/${PREFIX}*`], cwd);
|
|
112
|
+
return r.ok && r.out ? r.out.split('\n').filter(Boolean) : [];
|
|
113
|
+
} catch {
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The branch sessions merge back into. Preference order:
|
|
120
|
+
* 1. the branch HEAD was on when the session opened (recorded as
|
|
121
|
+
* `branch.<session>.zz-base` config at open — survives crashes)
|
|
122
|
+
* 2. origin/HEAD's branch, if it exists locally
|
|
123
|
+
* 3. local `main`, then `master`
|
|
124
|
+
* 4. the current branch, if it isn't itself a session branch
|
|
125
|
+
*/
|
|
126
|
+
export function mainBranch(cwd) {
|
|
127
|
+
try {
|
|
128
|
+
for (const b of listSessionBranches(cwd)) {
|
|
129
|
+
const base = git(['config', `branch.${b}.zz-base`], cwd);
|
|
130
|
+
if (base.ok && base.out && branchExists(cwd, base.out)) return base.out;
|
|
131
|
+
}
|
|
132
|
+
const oh = git(['symbolic-ref', 'refs/remotes/origin/HEAD'], cwd);
|
|
133
|
+
if (oh.ok && oh.out) {
|
|
134
|
+
const name = oh.out.replace('refs/remotes/origin/', '');
|
|
135
|
+
if (branchExists(cwd, name)) return name;
|
|
136
|
+
}
|
|
137
|
+
if (branchExists(cwd, 'main')) return 'main';
|
|
138
|
+
if (branchExists(cwd, 'master')) return 'master';
|
|
139
|
+
const cur = currentBranch(cwd);
|
|
140
|
+
return cur && !cur.startsWith(PREFIX) ? cur : null;
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Commits on the session branch beyond main (best-effort; 0 on any trouble). */
|
|
147
|
+
function countCheckpoints(cwd, branch) {
|
|
148
|
+
const main = mainBranch(cwd);
|
|
149
|
+
if (!main || main === branch) return 0;
|
|
150
|
+
const r = git(['rev-list', '--count', `${main}..${branch}`], cwd);
|
|
151
|
+
const n = r.ok ? Number.parseInt(r.out, 10) : NaN;
|
|
152
|
+
return Number.isFinite(n) ? n : 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* OPEN: create the session branch (dirty tree fine — changes ride along).
|
|
157
|
+
* already on this session's branch (host restart) → { ok:true, resumed:true }
|
|
158
|
+
* another session branch exists (leftover) → { ok:false, blocked:true, existing }
|
|
159
|
+
*/
|
|
160
|
+
export function openSession(cwd, sessionId) {
|
|
161
|
+
try {
|
|
162
|
+
const reason = disabledReason(cwd);
|
|
163
|
+
if (reason) return { ok: false, reason };
|
|
164
|
+
const target = sessionBranchName(sessionId);
|
|
165
|
+
const cur = currentBranch(cwd);
|
|
166
|
+
if (cur === target) return { ok: true, resumed: true, branch: target };
|
|
167
|
+
const existing = listSessionBranches(cwd);
|
|
168
|
+
if (existing.length) return { ok: false, blocked: true, existing: existing[0] };
|
|
169
|
+
const r = git(['checkout', '-q', '-b', target], cwd);
|
|
170
|
+
if (!r.ok) return { ok: false, reason: r.err || 'checkout-failed' };
|
|
171
|
+
git(['config', `branch.${target}.zz-base`, cur], cwd); // remember where to merge back (best-effort)
|
|
172
|
+
return { ok: true, branch: target };
|
|
173
|
+
} catch (e) {
|
|
174
|
+
return { ok: false, reason: String(e) };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// The secret family checkpoints must never commit — mirrors the seeded
|
|
179
|
+
// no-secret-reads guardrail (scaffold.mjs RULES_SEED: .env / id_rsa / .pem).
|
|
180
|
+
const SECRET_GLOBS = ['.env', '.env.*', '**/.env', '**/.env.*', '**/*.pem', '**/*.key', '**/id_rsa*'];
|
|
181
|
+
const SECRET_RE = /(^|\/)\.env(\.|$)|(^|\/)id_rsa[^/]*$|\.pem$|\.key$/;
|
|
182
|
+
|
|
183
|
+
/** How many dirty/untracked paths are secret-family (and so were excluded). */
|
|
184
|
+
function countExcludedSecrets(cwd) {
|
|
185
|
+
const out = git(['status', '--porcelain', '-uall'], cwd).out; // -uall: expand untracked dirs
|
|
186
|
+
if (!out) return 0;
|
|
187
|
+
let n = 0;
|
|
188
|
+
for (const line of out.split('\n')) {
|
|
189
|
+
const p = line.slice(3);
|
|
190
|
+
const path = (p.includes(' -> ') ? p.split(' -> ').pop() : p).replace(/^"|"$/g, '');
|
|
191
|
+
if (SECRET_RE.test(path)) n += 1;
|
|
192
|
+
}
|
|
193
|
+
return n;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** TURN: checkpoint commit — only ON a session branch, only when dirty. NEVER commits on main.
|
|
197
|
+
* Secret-family files are excluded from staging (left untracked/unstaged);
|
|
198
|
+
* the count of excluded paths is returned as `excludedSecrets` when > 0. */
|
|
199
|
+
export function checkpoint(cwd) {
|
|
200
|
+
try {
|
|
201
|
+
const blocked = unsafeReason(cwd);
|
|
202
|
+
if (blocked) return { ok: false, reason: blocked };
|
|
203
|
+
const cur = currentBranch(cwd);
|
|
204
|
+
if (!cur || !cur.startsWith(PREFIX)) return { ok: false, reason: 'not-on-session-branch' };
|
|
205
|
+
const base = countCheckpoints(cwd, cur);
|
|
206
|
+
if (!isDirty(cwd)) return { ok: true, committed: false, n: base };
|
|
207
|
+
const excludedSecrets = countExcludedSecrets(cwd);
|
|
208
|
+
const add = git(['add', '-A', '--', '.', ...SECRET_GLOBS.map((g) => `:(exclude,glob)${g}`)], cwd);
|
|
209
|
+
if (!add.ok) return { ok: false, reason: 'add-failed' };
|
|
210
|
+
if (git(['diff', '--cached', '--quiet'], cwd).ok) {
|
|
211
|
+
// everything dirty was secret material — never an empty commit
|
|
212
|
+
return { ok: true, committed: false, n: base, ...(excludedSecrets ? { excludedSecrets } : {}) };
|
|
213
|
+
}
|
|
214
|
+
const c = git(['commit', '-q', '-m', `zz: checkpoint ${base + 1}`], cwd);
|
|
215
|
+
if (!c.ok) return { ok: false, reason: c.err || 'commit-failed' };
|
|
216
|
+
return { ok: true, committed: true, n: base + 1, ...(excludedSecrets ? { excludedSecrets } : {}) };
|
|
217
|
+
} catch (e) {
|
|
218
|
+
return { ok: false, reason: String(e) };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The leftover detector: reflects any zz/session-* branch, checked out or not. */
|
|
223
|
+
export function sessionStatus(cwd) {
|
|
224
|
+
try {
|
|
225
|
+
const enabled = sessionGitEnabled(cwd);
|
|
226
|
+
const main = mainBranch(cwd);
|
|
227
|
+
const cur = currentBranch(cwd);
|
|
228
|
+
const onSessionBranch = !!cur && cur.startsWith(PREFIX);
|
|
229
|
+
const branches = listSessionBranches(cwd);
|
|
230
|
+
let active = null;
|
|
231
|
+
if (branches.length) {
|
|
232
|
+
const branch = branches[0];
|
|
233
|
+
const checkpoints = countCheckpoints(cwd, branch);
|
|
234
|
+
active = {
|
|
235
|
+
branch,
|
|
236
|
+
checkpoints,
|
|
237
|
+
dirty: cur === branch && isDirty(cwd),
|
|
238
|
+
// exploration-only session: checkpoints exist but the tree equals main
|
|
239
|
+
// (the empty-squash-with-checkpoints case doctor/status render specially)
|
|
240
|
+
noNetChanges: checkpoints > 0 && !!main && main !== branch && git(['diff', '--quiet', main, branch], cwd).ok,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
return { enabled, mainBranch: main, active, onSessionBranch };
|
|
244
|
+
} catch {
|
|
245
|
+
return { enabled: false, mainBranch: null, active: null, onSessionBranch: false };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const defaultTitle = (branch) => `${branch} · ${new Date().toISOString().slice(0, 10)}`;
|
|
250
|
+
|
|
251
|
+
/** Best-effort: drop squash leftovers so they can't leak into the user's next commit. */
|
|
252
|
+
function cleanupSquashState(cwd) {
|
|
253
|
+
const gd = gitDir(cwd);
|
|
254
|
+
if (!gd) return;
|
|
255
|
+
for (const f of ['SQUASH_MSG', 'MERGE_MSG']) {
|
|
256
|
+
try {
|
|
257
|
+
rmSync(join(gd, f), { force: true });
|
|
258
|
+
} catch {
|
|
259
|
+
/* best-effort */
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* END: squash-merge the session branch into main as ONE commit
|
|
266
|
+
* `session: <title>`, then delete the branch. Every ok:true return carries
|
|
267
|
+
* `mergedTo` (the branch actually merged into); when the recorded zz-base
|
|
268
|
+
* branch no longer exists, `warning:'base-branch-missing'` flags the fallback.
|
|
269
|
+
* conflict → abort (reset --merge), restore the prior checkout,
|
|
270
|
+
* { ok:false, conflict:true, branch, restoredTo } —
|
|
271
|
+
* restoredTo:null means the checkout back failed (stranded on main)
|
|
272
|
+
* empty squash → 0 checkpoints: no commit, still cleans up ({ mergedAs:null });
|
|
273
|
+
* WITH checkpoints: the branch is KEPT (history is never
|
|
274
|
+
* destroyed silently) → { ok:false,
|
|
275
|
+
* reason:'empty-squash-with-checkpoints', commits, branch }
|
|
276
|
+
*/
|
|
277
|
+
export function closeSession(cwd, { title } = {}) {
|
|
278
|
+
try {
|
|
279
|
+
const blocked = unsafeReason(cwd);
|
|
280
|
+
if (blocked) return { ok: false, reason: blocked };
|
|
281
|
+
const branches = listSessionBranches(cwd);
|
|
282
|
+
if (!branches.length) return { ok: false, reason: 'no-session-branch' };
|
|
283
|
+
const branch = branches[0];
|
|
284
|
+
const cur = currentBranch(cwd);
|
|
285
|
+
const baseCfg = git(['config', `branch.${branch}.zz-base`], cwd).out;
|
|
286
|
+
const main = mainBranch(cwd);
|
|
287
|
+
if (!main || main === branch) return { ok: false, reason: 'no-main-branch' };
|
|
288
|
+
// honesty: the branch we recorded at open is gone → we merge to a fallback
|
|
289
|
+
const baseMissing = !!baseCfg && !branchExists(cwd, baseCfg);
|
|
290
|
+
|
|
291
|
+
let excludedSecrets = 0;
|
|
292
|
+
if (cur === branch) {
|
|
293
|
+
const cp = checkpoint(cwd); // fold any uncommitted work into the squash
|
|
294
|
+
if (!cp.ok) return { ok: false, reason: cp.reason };
|
|
295
|
+
excludedSecrets = cp.excludedSecrets ?? 0;
|
|
296
|
+
} else if (isDirty(cwd)) {
|
|
297
|
+
// Loose changes on another branch are the USER's — never mix them into the squash.
|
|
298
|
+
return { ok: false, reason: 'dirty-worktree' };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const commits = countCheckpoints(cwd, branch);
|
|
302
|
+
/** Undo a failed merge and put the user back where they were. Returns the
|
|
303
|
+
* branch we actually landed on — null = the checkout back failed and the
|
|
304
|
+
* user is STRANDED ON MAIN (report it, never pretend otherwise). */
|
|
305
|
+
const restore = () => {
|
|
306
|
+
git(['reset', '--merge'], cwd);
|
|
307
|
+
cleanupSquashState(cwd);
|
|
308
|
+
if (!cur || cur === main) return cur ?? null;
|
|
309
|
+
return git(['checkout', '-q', cur], cwd).ok ? cur : null;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const co = git(['checkout', '-q', main], cwd);
|
|
313
|
+
if (!co.ok) return { ok: false, reason: co.err || 'checkout-main-failed' };
|
|
314
|
+
const merge = git(['merge', '--squash', branch], cwd);
|
|
315
|
+
if (!merge.ok) {
|
|
316
|
+
// conflict (or any squash failure): leave the repo exactly as before
|
|
317
|
+
return { ok: false, conflict: true, branch, restoredTo: restore() };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const extras = {
|
|
321
|
+
...(excludedSecrets ? { excludedSecrets } : {}),
|
|
322
|
+
...(baseMissing ? { warning: 'base-branch-missing' } : {}),
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
let mergedAs = null;
|
|
326
|
+
const nothingStaged = git(['diff', '--cached', '--quiet'], cwd).ok; // exit 1 = staged changes
|
|
327
|
+
if (nothingStaged) {
|
|
328
|
+
cleanupSquashState(cwd); // a stale SQUASH_MSG would hijack the user's next commit
|
|
329
|
+
if (commits > 0) {
|
|
330
|
+
// Exploration-only session: the squash is empty but real checkpoints
|
|
331
|
+
// exist. NEVER delete that history silently — keep the branch, put the
|
|
332
|
+
// user back, and report; `zuzuu session discard --yes` is the drop path.
|
|
333
|
+
if (cur && cur !== main) git(['checkout', '-q', cur], cwd);
|
|
334
|
+
return { ok: false, reason: 'empty-squash-with-checkpoints', commits, branch, ...extras };
|
|
335
|
+
}
|
|
336
|
+
} else {
|
|
337
|
+
const c = git(['commit', '-q', '-m', `session: ${title || defaultTitle(branch)}`], cwd);
|
|
338
|
+
if (!c.ok) {
|
|
339
|
+
return { ok: false, reason: c.err || 'commit-failed', restoredTo: restore() };
|
|
340
|
+
}
|
|
341
|
+
mergedAs = git(['rev-parse', 'HEAD'], cwd).out || null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
git(['config', '--unset', `branch.${branch}.zz-base`], cwd); // best-effort
|
|
345
|
+
const del = git(['branch', '-D', branch], cwd);
|
|
346
|
+
if (!del.ok) {
|
|
347
|
+
const warning = extras.warning ? `${extras.warning},branch-delete-failed` : 'branch-delete-failed';
|
|
348
|
+
return { ok: true, mergedAs, mergedTo: main, commits, ...extras, warning };
|
|
349
|
+
}
|
|
350
|
+
return { ok: true, mergedAs, mergedTo: main, commits, ...extras };
|
|
351
|
+
} catch (e) {
|
|
352
|
+
return { ok: false, reason: String(e) };
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Recovery: check the leftover session branch back out and keep working. */
|
|
357
|
+
export function continueSession(cwd) {
|
|
358
|
+
try {
|
|
359
|
+
const blocked = unsafeReason(cwd);
|
|
360
|
+
if (blocked) return { ok: false, reason: blocked };
|
|
361
|
+
const branches = listSessionBranches(cwd);
|
|
362
|
+
if (!branches.length) return { ok: false, reason: 'no-session-branch' };
|
|
363
|
+
const branch = branches[0];
|
|
364
|
+
if (currentBranch(cwd) === branch) return { ok: true, branch };
|
|
365
|
+
const r = git(['checkout', '-q', branch], cwd);
|
|
366
|
+
return r.ok ? { ok: true, branch } : { ok: false, reason: r.err || 'checkout-failed' };
|
|
367
|
+
} catch (e) {
|
|
368
|
+
return { ok: false, reason: String(e) };
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Recovery: drop the session branch and its checkpoints. The CALLER gates confirmation. */
|
|
373
|
+
export function discardSession(cwd) {
|
|
374
|
+
try {
|
|
375
|
+
const blocked = unsafeReason(cwd);
|
|
376
|
+
if (blocked) return { ok: false, reason: blocked };
|
|
377
|
+
const branches = listSessionBranches(cwd);
|
|
378
|
+
if (!branches.length) return { ok: false, reason: 'no-session-branch' };
|
|
379
|
+
const branch = branches[0];
|
|
380
|
+
const main = mainBranch(cwd);
|
|
381
|
+
if (!main || main === branch) return { ok: false, reason: 'no-main-branch' };
|
|
382
|
+
if (currentBranch(cwd) === branch) {
|
|
383
|
+
const co = git(['checkout', '-q', main], cwd);
|
|
384
|
+
if (!co.ok) return { ok: false, reason: co.err || 'checkout-main-failed' };
|
|
385
|
+
}
|
|
386
|
+
git(['config', '--unset', `branch.${branch}.zz-base`], cwd); // best-effort
|
|
387
|
+
const del = git(['branch', '-D', branch], cwd);
|
|
388
|
+
return del.ok ? { ok: true, branch } : { ok: false, reason: del.err || 'branch-delete-failed' };
|
|
389
|
+
} catch (e) {
|
|
390
|
+
return { ok: false, reason: String(e) };
|
|
391
|
+
}
|
|
392
|
+
}
|