@bivy/bivy 0.17.3-staging.9 → 0.18.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/bin/bivy.mjs +157 -12
- package/dist/apps/annotate.js +94 -0
- package/dist/apps/display-viewer.js +80 -7
- package/dist/apps/display.js +11 -1
- package/dist/apps/gateway.js +106 -14
- package/dist/apps/input.js +96 -0
- package/dist/apps/inspector.js +84 -29
- package/dist/apps/macos-display.js +170 -0
- package/dist/apps/macos-display.swift +873 -0
- package/dist/apps/menu.js +76 -0
- package/dist/apps/preview-shell.js +232 -16
- package/dist/apps/preview-stream.js +17 -3
- package/dist/apps/registry.js +76 -5
- package/dist/apps/review.js +111 -0
- package/dist/apps/rfb.js +69 -20
- package/dist/apps/screenshot.js +8 -2
- package/dist/apps/service.js +343 -13
- package/dist/apps/styles.css +162 -10
- package/dist/apps/tokens.css +6 -0
- package/dist/apps/types.js +17 -0
- package/dist/attach.js +37 -4
- package/dist/controllers/app-commands.js +16 -4
- package/dist/controllers/artifact-commands.js +42 -0
- package/dist/controllers/presence-commands.js +19 -0
- package/dist/protocol/client-command-routes.js +3 -1
- package/dist/protocol/client-command-schemas.js +19 -3
- package/dist/server.js +200 -21
- package/dist/session/attachment-store.js +15 -0
- package/dist/session/event-log.js +25 -3
- package/dist/session/presence.js +82 -0
- package/dist/session/run-terminal.js +113 -69
- package/dist/session/session-new-dedupe.js +1 -1
- package/dist/terminal.js +23 -0
- package/dist/worktree.js +40 -3
- package/package.json +1 -1
|
@@ -98,6 +98,44 @@ export function createRunTerminals(deps) {
|
|
|
98
98
|
}
|
|
99
99
|
return false;
|
|
100
100
|
}
|
|
101
|
+
// One conversation, one writer. A run pinned to a session that is also open as
|
|
102
|
+
// a chat owns it while the PTY lives: the chat locks (and says so on every
|
|
103
|
+
// device), and when the PTY ends the chat reloads what the terminal wrote.
|
|
104
|
+
function bindRun(record, termId) {
|
|
105
|
+
record.tuiTermId = termId;
|
|
106
|
+
void deps.sessionTerminalsRecord(record.id, { termId }).catch(() => { });
|
|
107
|
+
deps.broadcastTuiState(record.id, true);
|
|
108
|
+
}
|
|
109
|
+
function releaseRun(record, termId) {
|
|
110
|
+
if (record.tuiTermId !== termId)
|
|
111
|
+
return;
|
|
112
|
+
record.tuiTermId = undefined;
|
|
113
|
+
void deps.sessionTerminalsForget(record.id).catch(() => { });
|
|
114
|
+
record.tuiRefreshing = true;
|
|
115
|
+
deps.broadcastTuiState(record.id, false);
|
|
116
|
+
void deps.refreshRecordAfterTui(record);
|
|
117
|
+
}
|
|
118
|
+
function adoptLiveRun(record) {
|
|
119
|
+
if (record.tuiTermId)
|
|
120
|
+
return;
|
|
121
|
+
for (const id of runTerminals) {
|
|
122
|
+
const pinned = terminals.meta(id)?.sessionId;
|
|
123
|
+
if (pinned && (pinned === record.id || pinned === record.sessionFile)) {
|
|
124
|
+
bindRun(record, id);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/** Stop a run because its session is continuing as a chat. Tells every viewer
|
|
130
|
+
* where the session went first, so a terminal attached elsewhere (a laptop's
|
|
131
|
+
* `bivy run`) can offer to take it back instead of just printing "ended". */
|
|
132
|
+
function handRunToChat(termId, sessionId) {
|
|
133
|
+
deps.broadcast({ type: "terminal.closed", termId, reason: "chat", sessionId });
|
|
134
|
+
terminals.close(termId);
|
|
135
|
+
runTerminals.delete(termId);
|
|
136
|
+
runViewers.delete(termId);
|
|
137
|
+
deps.broadcast({ type: "terminal.closed", termId });
|
|
138
|
+
}
|
|
101
139
|
async function runTerminalList() {
|
|
102
140
|
const runs = terminals.list((meta) => meta.kind === "run").filter((t) => runTerminals.has(t.id));
|
|
103
141
|
let saved = [];
|
|
@@ -218,6 +256,18 @@ export function createRunTerminals(deps) {
|
|
|
218
256
|
emit({ type: "terminal.error", error: `Too many run terminals open (${runTerminals.size}/${deps.maxRunTerminals}). Close one (e.g. 'bivy kill') or raise BIVY_MAX_RUN_TERMINALS.` });
|
|
219
257
|
return undefined;
|
|
220
258
|
}
|
|
259
|
+
const owner = spec.sessionId && !spec.mux ? deps.findOpenSession(spec.sessionId) : undefined;
|
|
260
|
+
if (owner?.tuiTermId && runTerminals.has(owner.tuiTermId)) {
|
|
261
|
+
// Already open in a terminal: join it rather than start a second writer.
|
|
262
|
+
if (spec.clientId && (spec.cols !== undefined || spec.rows !== undefined))
|
|
263
|
+
terminals.setClientSize(owner.tuiTermId, spec.clientId, spec.cols || 80, spec.rows || 24);
|
|
264
|
+
emit({ type: "terminal.attached", termId: owner.tuiTermId, data: terminals.snapshot(owner.tuiTermId) ?? "" });
|
|
265
|
+
return owner.tuiTermId;
|
|
266
|
+
}
|
|
267
|
+
if (owner && deps.sessionBusy(owner)) {
|
|
268
|
+
emit({ type: "terminal.error", sessionId: owner.id, error: "This session is mid-turn in chat. Stop or finish the turn, then open it in the terminal." });
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
221
271
|
const active = deps.getActiveSession();
|
|
222
272
|
const workspace = spec.workspace || active?.session?.cwd || active?.worktree?.path || active?.workspace || deps.defaultWorkspace;
|
|
223
273
|
// Agent-owned integrations keep their native login untouched. Only an
|
|
@@ -260,6 +310,17 @@ export function createRunTerminals(deps) {
|
|
|
260
310
|
clearRunIdleNotify(id);
|
|
261
311
|
deps.broadcast({ type: "terminal.exit", termId: id, code });
|
|
262
312
|
deps.broadcast({ type: "terminal.closed", termId: id });
|
|
313
|
+
// A run that owned an open chat hands the conversation back to it; the
|
|
314
|
+
// chat keeps its own record, so none of the run bookkeeping below applies.
|
|
315
|
+
const bound = spec.sessionId ? deps.findOpenSession(spec.sessionId) : undefined;
|
|
316
|
+
if (bound?.tuiTermId === id) {
|
|
317
|
+
releaseRun(bound, id);
|
|
318
|
+
try {
|
|
319
|
+
deps.sessionListChanged();
|
|
320
|
+
}
|
|
321
|
+
catch { /* best-effort */ }
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
263
324
|
if (!spec.mux) {
|
|
264
325
|
// The durable record of this run, in order of fidelity: the agent's
|
|
265
326
|
// own session (pinned at launch), the session discovered in the
|
|
@@ -315,7 +376,9 @@ export function createRunTerminals(deps) {
|
|
|
315
376
|
},
|
|
316
377
|
});
|
|
317
378
|
runTerminals.add(id);
|
|
318
|
-
if (
|
|
379
|
+
if (owner)
|
|
380
|
+
bindRun(owner, id);
|
|
381
|
+
if (spec.sessionId && spec.agent && !spec.mux && !owner) {
|
|
319
382
|
const runtimeId = TAKEOVER_RUNTIME_BY_AGENT[spec.agent] ?? spec.agent;
|
|
320
383
|
try {
|
|
321
384
|
deps.upsertSessionMetadata({ id: spec.sessionId, runtimeId, agentName: spec.agent, workspace, name: name || undefined, source: "cli", status: "working" });
|
|
@@ -366,6 +429,13 @@ export function createRunTerminals(deps) {
|
|
|
366
429
|
: undefined;
|
|
367
430
|
if (!entry)
|
|
368
431
|
return { ok: false, status: 404, error: "No matching live run-terminal." };
|
|
432
|
+
// The chat already exists (the terminal was opened from it, or joined it):
|
|
433
|
+
// stopping the PTY is the whole handoff — its exit reloads the chat.
|
|
434
|
+
const owner = entry.meta.sessionId ? deps.findOpenSession(entry.meta.sessionId) : undefined;
|
|
435
|
+
if (owner?.tuiTermId === entry.id) {
|
|
436
|
+
handRunToChat(entry.id, owner.id);
|
|
437
|
+
return { ok: true, sessionId: owner.id, runtimeId: owner.runtimeId ?? "" };
|
|
438
|
+
}
|
|
369
439
|
const agent = entry.meta.agent ?? "";
|
|
370
440
|
const runtimeId = TAKEOVER_RUNTIME_BY_AGENT[agent];
|
|
371
441
|
if (!runtimeId)
|
|
@@ -385,10 +455,7 @@ export function createRunTerminals(deps) {
|
|
|
385
455
|
// second agent process until the next prompt. If preparation fails, the
|
|
386
456
|
// terminal remains live and the user loses nothing.
|
|
387
457
|
const record = await deps.createSession(workspace, pinned, { runtimeId, makeActive: true, source: "takeover" });
|
|
388
|
-
|
|
389
|
-
runTerminals.delete(entry.id);
|
|
390
|
-
runViewers.delete(entry.id);
|
|
391
|
-
deps.broadcast({ type: "terminal.closed", termId: entry.id });
|
|
458
|
+
handRunToChat(entry.id, record.id);
|
|
392
459
|
const resumeCommand = RESUME_CLI_BY_AGENT[agent]?.(pinned);
|
|
393
460
|
return { ok: true, sessionId: record.id, runtimeId, resumeCommand };
|
|
394
461
|
}
|
|
@@ -473,84 +540,61 @@ export function createRunTerminals(deps) {
|
|
|
473
540
|
return true;
|
|
474
541
|
}
|
|
475
542
|
case "terminal.open.tui": {
|
|
543
|
+
// "Open in terminal" for a chat session: its runtime's own interactive
|
|
544
|
+
// TUI, resuming the same conversation, as a daemon-owned run pinned to the
|
|
545
|
+
// session — so it outlives the device that opened it, every device can
|
|
546
|
+
// watch it, and the chat stays locked until it ends (single writer).
|
|
476
547
|
void (async () => {
|
|
477
|
-
const record = deps.
|
|
548
|
+
const record = typeof msg.sessionId === "string" && msg.sessionId ? deps.findOpenSession(msg.sessionId) : undefined;
|
|
478
549
|
if (!record) {
|
|
479
550
|
emit({ type: "terminal.error", error: "Session not found for TUI." });
|
|
480
551
|
return;
|
|
481
552
|
}
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
if (typeof msg.cols !== "undefined" || typeof msg.rows !== "undefined")
|
|
487
|
-
terminals.setClientSize(record.tuiTermId, clientId, Number(msg.cols) || 80, Number(msg.rows) || 24);
|
|
488
|
-
emit({ type: "terminal.attached", termId: record.tuiTermId, data: snapshot });
|
|
489
|
-
return;
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
if (deps.sessionBusy(record)) {
|
|
553
|
+
const cols = Number(msg.cols) || undefined;
|
|
554
|
+
const rows = Number(msg.rows) || undefined;
|
|
555
|
+
const live = record.tuiTermId && runTerminals.has(record.tuiTermId) ? record.tuiTermId : undefined;
|
|
556
|
+
if (!live && deps.sessionBusy(record)) {
|
|
493
557
|
emit({ type: "terminal.error", sessionId: record.id, error: "Finish or stop the current turn before opening the TUI." });
|
|
494
558
|
return;
|
|
495
559
|
}
|
|
496
|
-
let spec;
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
try {
|
|
510
|
-
const id = terminals.open({
|
|
511
|
-
workspace,
|
|
512
|
-
command: spec.command,
|
|
513
|
-
args: spec.args,
|
|
514
|
-
env: spec.env,
|
|
515
|
-
cols: Number(msg.cols) || undefined,
|
|
516
|
-
rows: Number(msg.rows) || undefined,
|
|
517
|
-
clientId,
|
|
518
|
-
onData: (data) => emit({ type: "terminal.output", termId: id, data }),
|
|
519
|
-
onExit: (code) => {
|
|
520
|
-
owned.delete(id);
|
|
521
|
-
if (record.tuiTermId === id) {
|
|
522
|
-
record.tuiTermId = undefined;
|
|
523
|
-
void deps.sessionTerminalsForget(record.id).catch(() => { });
|
|
524
|
-
record.tuiRefreshing = true;
|
|
525
|
-
deps.broadcastTuiState(record.id, false);
|
|
526
|
-
void deps.refreshRecordAfterTui(record);
|
|
527
|
-
}
|
|
528
|
-
emit({ type: "terminal.exit", termId: id, code });
|
|
529
|
-
},
|
|
530
|
-
});
|
|
531
|
-
owned.add(id);
|
|
532
|
-
record.tuiTermId = id;
|
|
533
|
-
void deps.sessionTerminalsRecord(record.id, { termId: id }).catch(() => { });
|
|
534
|
-
emit({ type: "terminal.opened", termId: id, workspace, sessionId: record.id, mode: "tui" });
|
|
535
|
-
deps.broadcastTuiState(record.id, true);
|
|
536
|
-
}
|
|
537
|
-
catch (error) {
|
|
538
|
-
emit({ type: "terminal.error", sessionId: record.id, error: error instanceof Error ? error.message : String(error) });
|
|
560
|
+
let spec = null;
|
|
561
|
+
if (!live) {
|
|
562
|
+
try {
|
|
563
|
+
spec = record.session.interactiveTuiCommand ? await record.session.interactiveTuiCommand() : null;
|
|
564
|
+
}
|
|
565
|
+
catch (error) {
|
|
566
|
+
emit({ type: "terminal.error", sessionId: record.id, error: error instanceof Error ? error.message : String(error) });
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if (!spec) {
|
|
570
|
+
emit({ type: "terminal.error", sessionId: record.id, error: "This runtime has no interactive TUI available on this node." });
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
539
573
|
}
|
|
574
|
+
const id = await openRunTerminal({
|
|
575
|
+
command: spec?.command ?? "",
|
|
576
|
+
args: spec?.args ?? [],
|
|
577
|
+
env: spec?.env,
|
|
578
|
+
name: record.session.getName() || undefined,
|
|
579
|
+
workspace: record.session.cwd || record.worktree?.path || record.workspace,
|
|
580
|
+
sessionId: record.id,
|
|
581
|
+
cols,
|
|
582
|
+
rows,
|
|
583
|
+
clientId,
|
|
584
|
+
}, emit);
|
|
585
|
+
if (id)
|
|
586
|
+
viewRun?.(id);
|
|
540
587
|
})();
|
|
541
588
|
return true;
|
|
542
589
|
}
|
|
543
590
|
case "terminal.close.tui": {
|
|
544
|
-
|
|
591
|
+
// "Use chat": hand the session's terminal back to its chat.
|
|
592
|
+
const record = typeof msg.sessionId === "string" && msg.sessionId ? deps.findOpenSession(msg.sessionId) : undefined;
|
|
545
593
|
const termId = record?.tuiTermId;
|
|
546
|
-
if (termId)
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
clearBellNotify(termId);
|
|
550
|
-
}
|
|
551
|
-
else if (record) {
|
|
594
|
+
if (record && termId && runTerminals.has(termId))
|
|
595
|
+
handRunToChat(termId, record.id);
|
|
596
|
+
else if (record)
|
|
552
597
|
deps.broadcastTuiState(record.id, false);
|
|
553
|
-
}
|
|
554
598
|
return true;
|
|
555
599
|
}
|
|
556
600
|
case "terminal.attach": {
|
|
@@ -607,5 +651,5 @@ export function createRunTerminals(deps) {
|
|
|
607
651
|
return false;
|
|
608
652
|
}
|
|
609
653
|
}
|
|
610
|
-
return { runTerminalList, openRunTerminal, takeoverRunTerminal, handleTerminalMessage, addRunViewer, dropRunViewer, hasRunTerminal, hasLiveRunForSession };
|
|
654
|
+
return { runTerminalList, openRunTerminal, takeoverRunTerminal, handleTerminalMessage, addRunViewer, dropRunViewer, hasRunTerminal, hasLiveRunForSession, adoptLiveRun };
|
|
611
655
|
}
|
package/dist/terminal.js
CHANGED
|
@@ -37,6 +37,14 @@ export function loadPty() {
|
|
|
37
37
|
// and the relay closed the socket with "Rate limit exceeded" the moment a
|
|
38
38
|
// phone/app attached to a live `bivy run` terminal.
|
|
39
39
|
const OUTPUT_FLUSH_MS = 16;
|
|
40
|
+
/**
|
|
41
|
+
* Closing sends a hangup (node-pty's kill, what closing a terminal window
|
|
42
|
+
* sends). A shell hung up while it's still starting can miss it and live on,
|
|
43
|
+
* holding the PTY: a leaked shell on the machine, and a handle that keeps Node
|
|
44
|
+
* from exiting. Until it exits, these follow (Unix; ms after close): the hangup
|
|
45
|
+
* again, then a kill of its whole process group.
|
|
46
|
+
*/
|
|
47
|
+
const CLOSE_FOLLOW_UPS = [[250, "SIGHUP"], [1000, "SIGKILL"]];
|
|
40
48
|
/**
|
|
41
49
|
* How much recent output to retain per terminal for scrollback replay on
|
|
42
50
|
* reconnect. A phone that backgrounds the PWA (or drops its network) reattaches
|
|
@@ -385,6 +393,21 @@ export class TerminalManager {
|
|
|
385
393
|
catch {
|
|
386
394
|
// already gone
|
|
387
395
|
}
|
|
396
|
+
if (process.platform === "win32")
|
|
397
|
+
return;
|
|
398
|
+
let exited = false;
|
|
399
|
+
void entry.exitPromise.then(() => { exited = true; });
|
|
400
|
+
for (const [delay, signal] of CLOSE_FOLLOW_UPS) {
|
|
401
|
+
setTimeout(() => {
|
|
402
|
+
if (exited)
|
|
403
|
+
return;
|
|
404
|
+
// The PTY's child leads its own process group: a kill takes its jobs too.
|
|
405
|
+
try {
|
|
406
|
+
process.kill(signal === "SIGKILL" ? -entry.proc.pid : entry.proc.pid, signal);
|
|
407
|
+
}
|
|
408
|
+
catch { /* already gone */ }
|
|
409
|
+
}, delay).unref();
|
|
410
|
+
}
|
|
388
411
|
}
|
|
389
412
|
has(id) {
|
|
390
413
|
return this.terminals.has(id);
|
package/dist/worktree.js
CHANGED
|
@@ -4,6 +4,7 @@ import { execFile } from "node:child_process";
|
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
import fs from "node:fs";
|
|
6
6
|
import path from "node:path";
|
|
7
|
+
import { gitNonInteractiveEnv } from "./git-auth.js";
|
|
7
8
|
import { cowProvisionDeps, provisionDepsByInstall } from "./worktree-provision.js";
|
|
8
9
|
const exec = promisify(execFile);
|
|
9
10
|
/** Slugify arbitrary text into a safe git branch / directory segment. */
|
|
@@ -86,7 +87,7 @@ export async function createWorktree(opts) {
|
|
|
86
87
|
const localExists = await refExists(repoRoot, `refs/heads/${branch}`);
|
|
87
88
|
const remoteExists = !localExists && await refExists(repoRoot, `refs/remotes/origin/${branch}`);
|
|
88
89
|
if (localExists) {
|
|
89
|
-
await
|
|
90
|
+
await worktreeAdd(repoRoot, [wtPath, branch]);
|
|
90
91
|
}
|
|
91
92
|
else {
|
|
92
93
|
if (remoteExists)
|
|
@@ -98,10 +99,10 @@ export async function createWorktree(opts) {
|
|
|
98
99
|
// Empty repo (e.g. a freshly created GitHub repo with no commits): HEAD is
|
|
99
100
|
// unborn, so it can't seed a worktree. Start the branch as an orphan —
|
|
100
101
|
// the session's first commit becomes the repo's root commit.
|
|
101
|
-
await
|
|
102
|
+
await worktreeAdd(repoRoot, ["--orphan", "-b", branch, wtPath]);
|
|
102
103
|
}
|
|
103
104
|
else {
|
|
104
|
-
await
|
|
105
|
+
await worktreeAdd(repoRoot, ["-b", branch, wtPath, base]);
|
|
105
106
|
}
|
|
106
107
|
}
|
|
107
108
|
// Opportunistically reuse a sibling worktree's installed deps (node_modules,
|
|
@@ -120,6 +121,42 @@ export async function createWorktree(opts) {
|
|
|
120
121
|
}
|
|
121
122
|
return { path: wtPath, branch, repoRoot };
|
|
122
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* `git worktree add`, non-interactively. Managed repos are partial clones
|
|
126
|
+
* (`--filter=blob:none`), so the checkout lazily fetches blobs from origin and
|
|
127
|
+
* needs credentials. The daemon has no TTY: without GIT_TERMINAL_PROMPT=0 a
|
|
128
|
+
* credential miss falls through to git's terminal prompt, which fails with the
|
|
129
|
+
* cryptic "could not read Username …: Device not configured" (macOS ENXIO).
|
|
130
|
+
*/
|
|
131
|
+
async function worktreeAdd(repoRoot, args) {
|
|
132
|
+
try {
|
|
133
|
+
await exec("git", ["-C", repoRoot, "worktree", "add", ...args], { env: gitNonInteractiveEnv() });
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
const stderr = String(error.stderr ?? "");
|
|
137
|
+
const friendly = describeWorktreeAddError(stderr);
|
|
138
|
+
if (friendly)
|
|
139
|
+
throw new Error(friendly, { cause: error });
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Turn a `git worktree add` failure caused by the lazy blob fetch into a message
|
|
145
|
+
* a person can act on, with git's output kept below as details. Returns
|
|
146
|
+
* undefined for any other failure (the raw error is already specific).
|
|
147
|
+
*/
|
|
148
|
+
export function describeWorktreeAddError(stderr) {
|
|
149
|
+
const detail = stderr.trim();
|
|
150
|
+
const auth = /could not read (Username|Password)|terminal prompts disabled|Authentication failed|Invalid username or (password|token)|Permission to .* denied/i.test(detail);
|
|
151
|
+
const promisor = /from promisor remote/i.test(detail);
|
|
152
|
+
if (auth) {
|
|
153
|
+
return `Couldn't create the session's worktree: git had to download repository files from GitHub, but no GitHub credentials were available on this machine. Reconnect GitHub for this machine in Bivy, then start the session again.\n\n${detail}`;
|
|
154
|
+
}
|
|
155
|
+
if (promisor) {
|
|
156
|
+
return `Couldn't create the session's worktree: git had to download repository files from the remote and the download failed. Check this machine's network connection, then start the session again.\n\n${detail}`;
|
|
157
|
+
}
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
123
160
|
/** True if `ref` (a fully-qualified ref) resolves in the repo. */
|
|
124
161
|
async function refExists(repoRoot, ref) {
|
|
125
162
|
try {
|