@nonbot/cli 0.5.15 → 0.6.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.
@@ -0,0 +1,271 @@
1
+ import { spawnSync as nodeSpawnSync } from 'node:child_process';
2
+ import { assertValidBranch } from './names.js';
3
+ import { makeEvent } from './progress-events.js';
4
+ export function planMergeOrder(branches, baseBranch) {
5
+ assertValidBranch(baseBranch, 'base branch');
6
+ for (const b of branches)
7
+ assertValidBranch(b, 'branch');
8
+ const seen = new Set();
9
+ const out = [];
10
+ for (const b of branches) {
11
+ if (b === baseBranch)
12
+ continue;
13
+ if (seen.has(b))
14
+ continue;
15
+ seen.add(b);
16
+ out.push(b);
17
+ }
18
+ out.sort();
19
+ return out;
20
+ }
21
+ export function tokenizeGateCommand(cmd) {
22
+ const tokens = [];
23
+ let cur = '';
24
+ let inSingle = false;
25
+ let inDouble = false;
26
+ let started = false;
27
+ for (let i = 0; i < cmd.length; i++) {
28
+ const ch = cmd[i];
29
+ if (inSingle) {
30
+ if (ch === "'")
31
+ inSingle = false;
32
+ else
33
+ cur += ch;
34
+ continue;
35
+ }
36
+ if (inDouble) {
37
+ if (ch === '"')
38
+ inDouble = false;
39
+ else
40
+ cur += ch;
41
+ continue;
42
+ }
43
+ if (ch === "'") {
44
+ inSingle = true;
45
+ started = true;
46
+ continue;
47
+ }
48
+ if (ch === '"') {
49
+ inDouble = true;
50
+ started = true;
51
+ continue;
52
+ }
53
+ if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
54
+ if (cur.length > 0 || started) {
55
+ tokens.push(cur);
56
+ cur = '';
57
+ started = false;
58
+ }
59
+ continue;
60
+ }
61
+ cur += ch;
62
+ started = true;
63
+ }
64
+ if (cur.length > 0 || started)
65
+ tokens.push(cur);
66
+ return tokens;
67
+ }
68
+ function runGit(spawnImpl, repoRoot, args) {
69
+ return spawnImpl('git', ['-C', repoRoot, ...args], {
70
+ encoding: 'utf-8',
71
+ timeout: 60_000,
72
+ windowsHide: true,
73
+ });
74
+ }
75
+ function countConflictFiles(stdout) {
76
+ let n = 0;
77
+ for (const line of stdout.split('\n')) {
78
+ if (/^CONFLICT\b/.test(line.trim()))
79
+ n += 1;
80
+ }
81
+ return n;
82
+ }
83
+ export function runReconcile(args) {
84
+ const { repoRoot, baseBranch, buildTestCommand, sessionId = 'choir', spawnImpl = nodeSpawnSync, emit = () => { }, now = () => Date.now(), random, } = args;
85
+ const order = planMergeOrder(args.branches, baseBranch);
86
+ const evDeps = { now, random };
87
+ const ts = () => Math.floor(now());
88
+ const total = order.length;
89
+ const startEvent = makeEvent({
90
+ sessionId,
91
+ engine: 'choir',
92
+ paneId: null,
93
+ stage: 'reconcile-started',
94
+ summary: `Reconciling ${total} branch${total === 1 ? '' : 'es'} into ${baseBranch}`,
95
+ ts: ts(),
96
+ refs: { branch: baseBranch },
97
+ metrics: { paneTotal: total },
98
+ }, evDeps);
99
+ emit(startEvent);
100
+ const parentEventId = startEvent.eventId;
101
+ const gateTokens = buildTestCommand.trim() ? tokenizeGateCommand(buildTestCommand) : [];
102
+ const merged = [];
103
+ for (let i = 0; i < order.length; i++) {
104
+ const branch = order[i];
105
+ const r = runGit(spawnImpl, repoRoot, ['merge', '--no-ff', branch]);
106
+ const stdout = typeof r.stdout === 'string' ? r.stdout : '';
107
+ const conflictFiles = countConflictFiles(stdout);
108
+ const isConflict = r.status !== 0 && (conflictFiles > 0 || /^CONFLICT\b/m.test(stdout));
109
+ if (isConflict) {
110
+ runGit(spawnImpl, repoRoot, ['merge', '--abort']);
111
+ emitBlockedMergeStep(emit, evDeps, {
112
+ sessionId,
113
+ parentEventId,
114
+ branch,
115
+ baseBranch,
116
+ conflictFiles,
117
+ index: i + 1,
118
+ total,
119
+ ts: ts(),
120
+ });
121
+ emit(blockedEvent(evDeps, {
122
+ sessionId,
123
+ parentEventId,
124
+ branch,
125
+ baseBranch,
126
+ reason: 'merge-conflict',
127
+ conflictFiles,
128
+ merged: merged.length,
129
+ total,
130
+ ts: ts(),
131
+ }));
132
+ return { blocked: true, merged, conflictedBranch: branch, reason: 'merge-conflict' };
133
+ }
134
+ if (r.status !== 0) {
135
+ runGit(spawnImpl, repoRoot, ['merge', '--abort']);
136
+ emit(blockedEvent(evDeps, {
137
+ sessionId,
138
+ parentEventId,
139
+ branch,
140
+ baseBranch,
141
+ reason: 'merge-error',
142
+ merged: merged.length,
143
+ total,
144
+ ts: ts(),
145
+ }));
146
+ return { blocked: true, merged, erroredBranch: branch, reason: 'merge-error' };
147
+ }
148
+ emit(makeEvent({
149
+ sessionId,
150
+ engine: 'choir',
151
+ paneId: null,
152
+ parentEventId,
153
+ stage: 'merge-step',
154
+ summary: `Merged ${branch} into ${baseBranch}`,
155
+ ts: ts(),
156
+ refs: { branch },
157
+ metrics: { storyIndex: i + 1, storyTotal: total, panesComplete: merged.length + 1 },
158
+ }, evDeps));
159
+ if (gateTokens.length > 0) {
160
+ const [gateCmd, ...gateArgs] = gateTokens;
161
+ const g = spawnImpl(gateCmd, gateArgs, {
162
+ cwd: repoRoot,
163
+ encoding: 'utf-8',
164
+ timeout: 600_000,
165
+ windowsHide: true,
166
+ });
167
+ if (g.status !== 0) {
168
+ emit(gateEvent(evDeps, {
169
+ sessionId,
170
+ parentEventId,
171
+ branch,
172
+ pass: false,
173
+ exitCode: typeof g.status === 'number' ? g.status : 1,
174
+ index: i + 1,
175
+ total,
176
+ ts: ts(),
177
+ }));
178
+ emit(blockedEvent(evDeps, {
179
+ sessionId,
180
+ parentEventId,
181
+ branch,
182
+ baseBranch,
183
+ reason: 'build-failed',
184
+ merged: merged.length,
185
+ total,
186
+ ts: ts(),
187
+ }));
188
+ return { blocked: true, merged, gateFailedAfter: branch, reason: 'build-failed' };
189
+ }
190
+ emit(gateEvent(evDeps, {
191
+ sessionId,
192
+ parentEventId,
193
+ branch,
194
+ pass: true,
195
+ exitCode: 0,
196
+ index: i + 1,
197
+ total,
198
+ ts: ts(),
199
+ }));
200
+ }
201
+ merged.push(branch);
202
+ }
203
+ emit(makeEvent({
204
+ sessionId,
205
+ engine: 'choir',
206
+ paneId: null,
207
+ parentEventId,
208
+ stage: 'reconcile-complete',
209
+ summary: `Reconciled ${merged.length} branch${merged.length === 1 ? '' : 'es'} into ${baseBranch}, build green`,
210
+ ts: ts(),
211
+ refs: { branch: baseBranch },
212
+ metrics: { paneTotal: total, panesComplete: merged.length },
213
+ }, evDeps));
214
+ return { blocked: false, merged };
215
+ }
216
+ function emitBlockedMergeStep(emit, deps, o) {
217
+ const base = makeEvent({
218
+ sessionId: o.sessionId,
219
+ engine: 'choir',
220
+ paneId: null,
221
+ parentEventId: o.parentEventId,
222
+ stage: 'merge-step',
223
+ summary: `Conflict merging ${o.branch} into ${o.baseBranch} (${o.conflictFiles} file${o.conflictFiles === 1 ? '' : 's'})`,
224
+ ts: o.ts,
225
+ refs: { branch: o.branch, blockReason: 'merge-conflict' },
226
+ metrics: { filesChanged: o.conflictFiles, storyIndex: o.index, storyTotal: o.total },
227
+ }, deps);
228
+ emit({ ...base, severity: 'blocked' });
229
+ }
230
+ function gateEvent(deps, o) {
231
+ const base = makeEvent({
232
+ sessionId: o.sessionId,
233
+ engine: 'choir',
234
+ paneId: null,
235
+ parentEventId: o.parentEventId,
236
+ stage: 'build-gate',
237
+ summary: o.pass
238
+ ? `Build gate passed after ${o.branch}`
239
+ : `Build gate FAILED after ${o.branch}`,
240
+ ts: o.ts,
241
+ refs: o.pass ? { branch: o.branch } : { branch: o.branch, blockReason: 'build-failed' },
242
+ metrics: { storyIndex: o.index, storyTotal: o.total, exitCode: o.exitCode },
243
+ }, deps);
244
+ return o.pass ? base : { ...base, severity: 'blocked' };
245
+ }
246
+ function blockedEvent(deps, o) {
247
+ const blockReason = o.reason === 'merge-conflict'
248
+ ? 'merge-conflict'
249
+ : o.reason === 'build-failed'
250
+ ? 'build-failed'
251
+ : undefined;
252
+ const summary = o.reason === 'merge-conflict'
253
+ ? `BLOCKED — conflict on ${o.branch}; human merge gate required`
254
+ : o.reason === 'build-failed'
255
+ ? `BLOCKED — build gate failed after ${o.branch}`
256
+ : `BLOCKED — merge of ${o.branch} could not proceed`;
257
+ const metrics = { panesComplete: o.merged, paneTotal: o.total };
258
+ if (typeof o.conflictFiles === 'number')
259
+ metrics.filesChanged = o.conflictFiles;
260
+ return makeEvent({
261
+ sessionId: o.sessionId,
262
+ engine: 'choir',
263
+ paneId: null,
264
+ parentEventId: o.parentEventId,
265
+ stage: 'reconcile-blocked',
266
+ summary,
267
+ ts: o.ts,
268
+ refs: blockReason ? { branch: o.branch, blockReason } : { branch: o.branch },
269
+ metrics,
270
+ }, deps);
271
+ }
@@ -0,0 +1,53 @@
1
+ export const NAME_RE = /^[a-z0-9][a-z0-9-]{0,30}$/;
2
+ export const BRANCH_RE = /^[a-z0-9][a-z0-9/-]{0,60}$/;
3
+ export const PANE_ID_RE = /^%\d+$/;
4
+ export const SUMMARY_MAX = 200;
5
+ export const LOCAL_TEXT_MAX = 4096;
6
+ export const SCHEMA_VERSION = 1;
7
+ export const EGRESS_ALLOWED_TOP_KEYS = Object.freeze([
8
+ 'schemaVersion',
9
+ 'eventId',
10
+ 'sessionId',
11
+ 'engine',
12
+ 'paneId',
13
+ 'parentEventId',
14
+ 'stage',
15
+ 'category',
16
+ 'severity',
17
+ 'terminal',
18
+ 'summary',
19
+ 'ts',
20
+ 'metrics',
21
+ 'refs',
22
+ ]);
23
+ export const EGRESS_FORBIDDEN_KEYS = Object.freeze([
24
+ 'paths',
25
+ 'path',
26
+ 'worktreePath',
27
+ 'diff',
28
+ 'patch',
29
+ 'transcript',
30
+ 'content',
31
+ 'fileContents',
32
+ 'token',
33
+ 'pat',
34
+ 'secret',
35
+ 'apiKey',
36
+ 'nonce',
37
+ 'msg',
38
+ ]);
39
+ export const HEALTH_DEFAULTS = Object.freeze({
40
+ SLOW: 90_000,
41
+ STALL: 8 * 60_000,
42
+ AWAIT_CONFIRM: 20_000,
43
+ LOOP_WINDOW: 5 * 60_000,
44
+ POLL: 5_000,
45
+ CRASH_GRACE: 10_000,
46
+ });
47
+ export const SEVERITY_WEIGHT = Object.freeze({
48
+ info: 0,
49
+ progress: 1,
50
+ 'attention-needed': 2,
51
+ blocked: 3,
52
+ error: 4,
53
+ });
@@ -0,0 +1,128 @@
1
+ import { spawnSync as nodeSpawnSync } from 'node:child_process';
2
+ import nodeFs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { worktreePathFor, branchFor, assertValidBranch } from './names.js';
5
+ const defaultFs = {
6
+ existsSync: nodeFs.existsSync,
7
+ readFileSync: nodeFs.readFileSync,
8
+ writeFileSync: nodeFs.writeFileSync,
9
+ };
10
+ function runGit(spawnImpl, cwd, args) {
11
+ return spawnImpl('git', ['-C', cwd, ...args], {
12
+ encoding: 'utf-8',
13
+ timeout: 15_000,
14
+ windowsHide: true,
15
+ });
16
+ }
17
+ function assertOk(r, op) {
18
+ if (r.status !== 0) {
19
+ throw new Error(`git ${op} failed`);
20
+ }
21
+ }
22
+ export function addWorktree(args) {
23
+ const { repoRoot, sessionName, paneName, baseBranch, spawnImpl = nodeSpawnSync } = args;
24
+ const worktreePath = worktreePathFor(repoRoot, sessionName, paneName);
25
+ const branch = branchFor(sessionName, paneName);
26
+ assertValidBranch(baseBranch, 'base branch');
27
+ const r = runGit(spawnImpl, repoRoot, [
28
+ 'worktree',
29
+ 'add',
30
+ worktreePath,
31
+ '-b',
32
+ branch,
33
+ baseBranch,
34
+ ]);
35
+ assertOk(r, 'worktree add');
36
+ return { worktreePath, branch };
37
+ }
38
+ export function listWorktrees(repoRoot, spawnImpl = nodeSpawnSync) {
39
+ const r = runGit(spawnImpl, repoRoot, ['worktree', 'list', '--porcelain']);
40
+ assertOk(r, 'worktree list');
41
+ const out = typeof r.stdout === 'string' ? r.stdout : '';
42
+ const entries = [];
43
+ let cur = null;
44
+ const flush = () => {
45
+ if (cur)
46
+ entries.push(cur);
47
+ cur = null;
48
+ };
49
+ for (const raw of out.split('\n')) {
50
+ const line = raw.replace(/\r$/, '');
51
+ if (line === '') {
52
+ flush();
53
+ continue;
54
+ }
55
+ const sp = line.indexOf(' ');
56
+ const key = sp === -1 ? line : line.slice(0, sp);
57
+ const val = sp === -1 ? '' : line.slice(sp + 1);
58
+ switch (key) {
59
+ case 'worktree':
60
+ flush();
61
+ cur = { worktree: val, detached: false, bare: false };
62
+ break;
63
+ case 'HEAD':
64
+ if (cur)
65
+ cur.head = val;
66
+ break;
67
+ case 'branch':
68
+ if (cur)
69
+ cur.branch = val.replace(/^refs\/heads\//, '');
70
+ break;
71
+ case 'detached':
72
+ if (cur)
73
+ cur.detached = true;
74
+ break;
75
+ case 'bare':
76
+ if (cur)
77
+ cur.bare = true;
78
+ break;
79
+ default:
80
+ break;
81
+ }
82
+ }
83
+ flush();
84
+ return entries;
85
+ }
86
+ export function removeWorktree(args) {
87
+ const { repoRoot, worktreePath, spawnImpl = nodeSpawnSync } = args;
88
+ const r = runGit(spawnImpl, repoRoot, ['worktree', 'remove', worktreePath]);
89
+ assertOk(r, 'worktree remove');
90
+ }
91
+ export function ensureChoirGitignored(repoRoot, fsImpl = defaultFs) {
92
+ const giPath = path.join(repoRoot, '.gitignore');
93
+ const ENTRY = '.choir/';
94
+ let existing = '';
95
+ if (fsImpl.existsSync(giPath)) {
96
+ existing = fsImpl.readFileSync(giPath, 'utf-8');
97
+ }
98
+ const lines = existing.split('\n').map((l) => l.replace(/\r$/, ''));
99
+ if (lines.includes(ENTRY))
100
+ return;
101
+ const needsNewline = existing.length > 0 && !existing.endsWith('\n');
102
+ const next = existing + (needsNewline ? '\n' : '') + ENTRY + '\n';
103
+ fsImpl.writeFileSync(giPath, next);
104
+ }
105
+ export function pollGitStatus(worktreePath, spawnImpl = nodeSpawnSync, base = 'HEAD') {
106
+ assertValidBranch(base, 'base ref');
107
+ const st = runGit(spawnImpl, worktreePath, ['status', '--porcelain']);
108
+ assertOk(st, 'status');
109
+ const stdout = typeof st.stdout === 'string' ? st.stdout : '';
110
+ const dirtyPaths = [];
111
+ for (const raw of stdout.split('\n')) {
112
+ const line = raw.replace(/\r$/, '');
113
+ if (line.trim() === '')
114
+ continue;
115
+ let p = line.slice(3);
116
+ const arrow = p.indexOf(' -> ');
117
+ if (arrow !== -1)
118
+ p = p.slice(arrow + 4);
119
+ p = p.replace(/^"(.*)"$/, '$1');
120
+ if (p.length > 0)
121
+ dirtyPaths.push(p);
122
+ }
123
+ const rev = runGit(spawnImpl, worktreePath, ['rev-list', '--count', `${base}..HEAD`]);
124
+ assertOk(rev, 'rev-list');
125
+ const revOut = typeof rev.stdout === 'string' ? rev.stdout.trim() : '';
126
+ const commitsAhead = Number.parseInt(revOut, 10) || 0;
127
+ return { dirtyPaths, dirtyFiles: dirtyPaths.length, commitsAhead };
128
+ }
@@ -60,7 +60,8 @@ export async function checkCompletions(opts) {
60
60
  const ok = await postCompletion(baseUrl, pat, id, opts.fetchImpl);
61
61
  if (ok) {
62
62
  reported.push(id);
63
- opts.log?.(`✓ ${id} · run completed (pane closed)\n`);
63
+ const card = opts.renderComplete?.(id);
64
+ opts.log?.(card && card.length > 0 ? card : `✓ ${id} · run completed (pane closed)\n`);
64
65
  }
65
66
  }
66
67
  return reported;
@@ -287,6 +287,33 @@ export function pollTick(args) {
287
287
  const statusSeg = c.muted(statusText, stream);
288
288
  return `${ts} ${pollCount} ${sep} ${sleepSeg} ${sep} ${statusSeg}`;
289
289
  }
290
+ export function formatElapsed(ms) {
291
+ if (!Number.isFinite(ms) || ms < 0)
292
+ return '0s';
293
+ const totalSec = Math.floor(ms / 1000);
294
+ if (totalSec < 60)
295
+ return `${totalSec}s`;
296
+ const totalMin = Math.floor(totalSec / 60);
297
+ if (totalMin < 60)
298
+ return `${totalMin}m ${totalSec % 60}s`;
299
+ const hours = Math.floor(totalMin / 60);
300
+ return `${hours}h ${totalMin % 60}m`;
301
+ }
302
+ function summaryAnchor(stream) {
303
+ const glyph = asciiOnly() ? '*' : '◆';
304
+ return c.cyan(glyph, stream);
305
+ }
306
+ export function runSummary(args) {
307
+ const stream = args.stream ?? process.stdout;
308
+ const failed = args.failed ?? 0;
309
+ const sep = c.muted('·', stream);
310
+ const segs = [];
311
+ segs.push(c.green(`${args.running} running`, stream));
312
+ segs.push(c.muted(`${args.done} done`, stream));
313
+ if (failed > 0)
314
+ segs.push(c.red(`${failed} failed`, stream));
315
+ return `${summaryAnchor(stream)} ` + segs.join(` ${sep} `);
316
+ }
290
317
  export function fixBlock(args) {
291
318
  const stream = args.stream ?? process.stdout;
292
319
  const title = c.amber(`| ${args.title.toUpperCase()} |`, stream);
@@ -0,0 +1,51 @@
1
+ import { spawnSync as nodeSpawnSync } from 'node:child_process';
2
+ const STATE_GLYPH = {
3
+ running: '▶',
4
+ completed: '✓',
5
+ failed: '✗',
6
+ stopping: '■',
7
+ };
8
+ const STATE_GLYPH_ASCII = {
9
+ running: '>',
10
+ completed: 'OK',
11
+ failed: 'XX',
12
+ stopping: '#',
13
+ };
14
+ function asciiOnly(env = process.env) {
15
+ return env.NONBOT_ASCII_ONLY === '1' || env.NONBOT_ASCII_ONLY === 'true';
16
+ }
17
+ export function paneStateGlyph(state, env = process.env) {
18
+ return asciiOnly(env) ? STATE_GLYPH_ASCII[state] : STATE_GLYPH[state];
19
+ }
20
+ export const PANE_TITLE_MAX = 44;
21
+ export function formatPaneTitle(state, story, env = process.env) {
22
+ const glyph = paneStateGlyph(state, env);
23
+ const cleaned = (story ?? '')
24
+ .replace(/[\x00-\x1f\x7f]/g, ' ')
25
+ .replace(/ {2,}/g, ' ')
26
+ .trim();
27
+ const label = cleaned.length > 0 ? cleaned : 'nonbot run';
28
+ const title = `${glyph} ${label}`;
29
+ return title.length > PANE_TITLE_MAX ? title.slice(0, PANE_TITLE_MAX - 1) + '…' : title;
30
+ }
31
+ export function buildPaneTitleCommand(paneId, state, story, env = process.env) {
32
+ if (!/^%\d+$/.test(paneId))
33
+ return null;
34
+ const title = formatPaneTitle(state, story, env);
35
+ return { cmd: 'tmux', args: ['select-pane', '-t', paneId, '-T', title] };
36
+ }
37
+ export function applyPaneTitle(paneId, state, story, spawnImpl = nodeSpawnSync, env = process.env) {
38
+ const command = buildPaneTitleCommand(paneId, state, story, env);
39
+ if (!command)
40
+ return false;
41
+ try {
42
+ spawnImpl(command.cmd, command.args, {
43
+ encoding: 'utf-8',
44
+ timeout: 1000,
45
+ windowsHide: true,
46
+ });
47
+ }
48
+ catch {
49
+ }
50
+ return true;
51
+ }
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.5.15';
1
+ export const VERSION = '0.6.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nonbot/cli",
3
- "version": "0.5.15",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "The local host for non.bot ▶ Run — opens a terminal on your machine and starts the work in your linked repo.",
6
6
  "license": "UNLICENSED",