codebay 0.14.0 → 0.15.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/.mochi/manifest.json +526 -455
- package/.mochi/svelte-client/HydratableIsland-smgsf0ma.js +1 -0
- package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-9vc087ep.js +2 -0
- package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-a48xq47a.js +14 -0
- package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-d4zehrqz.js +3 -0
- package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-t5175pf5.js +44 -0
- package/.mochi/svelte-client/chunk-0mstgbtr.js +2 -0
- package/.mochi/svelte-client/chunk-0q9g0kpg.js +1 -0
- package/.mochi/svelte-client/chunk-3fwjzw1r.js +1 -0
- package/.mochi/svelte-client/{chunk-spk1b2r4.js → chunk-5pg0be5f.js} +1 -1
- package/.mochi/svelte-client/{chunk-ccfh8hxz.js → chunk-a2hty99b.js} +1 -1
- package/.mochi/svelte-client/{chunk-0mc09cyb.js → chunk-ds9yn9cq.js} +1 -1
- package/.mochi/svelte-client/{chunk-phf7v5jj.js → chunk-fn03gxer.js} +1 -1
- package/.mochi/svelte-client/chunk-jhgq7f65.js +2 -0
- package/.mochi/svelte-client/chunk-p5a07kx1.js +1 -0
- package/.mochi/svelte-client/chunk-p5wpyhsf.js +2 -0
- package/.mochi/svelte-client/chunk-t2kfvtb6.js +2 -0
- package/.mochi/svelte-client/{chunk-x0m3k0rn.js → chunk-v3wg9mdy.js} +1 -1
- package/.mochi/svelte-compile/{App-94xsdn0w.server.js → App-68f9a9fq.server.js} +64 -59
- package/.mochi/svelte-compile/{Avatars-fnccw47h.server.js → Avatars-vyh3rwxx.server.js} +14 -29
- package/.mochi/svelte-compile/Instance-yfw7s5mp.server.js +627 -0
- package/.mochi/svelte-compile/{Settings-a7b11v90.server.js → Settings-0b5t9kw9.server.js} +145 -25
- package/.mochi/svelte-compile/{chunk-w0hca7va.js → chunk-0y3wrae9.js} +13 -1
- package/.mochi/svelte-compile/{chunk-ed46m58e.js → chunk-2qzc07gt.js} +13 -1
- package/.mochi/svelte-compile/chunk-ef3hvegw.js +21 -0
- package/.mochi/svelte-compile/chunk-f9td8ej2.js +24 -0
- package/.mochi/svelte-compile/{chunk-pqb403fh.js → chunk-g0eravvj.js} +5 -16
- package/.mochi/svelte-css/AgentLogBox-38adby5iygny7.css +1 -0
- package/.mochi/svelte-css/{InstanceCard-25z7nas7y98zk.css → InstanceCard-3vxozrp9uoads.css} +1 -1
- package/.mochi/svelte-css/{SettingsView-bccw34agd2bj.css → SettingsView-2400myxb9ot56.css} +1 -1
- package/README.md +32 -1
- package/migrations/0009_agent_runs.sql +24 -0
- package/migrations/0010_agent_run_model.sql +2 -0
- package/package.json +6 -2
- package/src/components/AgentLogBox.svelte +458 -0
- package/src/components/AppShell.svelte +11 -0
- package/src/components/FolderBrowser.svelte +13 -5
- package/src/components/InstanceCard.svelte +38 -0
- package/src/components/InstanceView.svelte +8 -0
- package/src/components/SettingsView.svelte +186 -0
- package/src/components/UiShowcase.svelte +4 -2
- package/src/container-injections/claude-statusline.ts +4 -1
- package/src/container-injections/github-credentials.ts +12 -1
- package/src/index.ts +32 -7
- package/src/lib/agent-run-stream.ts +246 -0
- package/src/lib/agent-runs.server.ts +724 -0
- package/src/lib/auth.server.ts +11 -2
- package/src/lib/build-freshness.server.ts +71 -0
- package/src/lib/config.server.ts +29 -6
- package/src/lib/container-files.server.ts +1 -1
- package/src/lib/data-dir-lock.server.ts +78 -0
- package/src/lib/db.server.ts +120 -1
- package/src/lib/devcontainer.server.ts +60 -14
- package/src/lib/docker-client.server.ts +11 -5
- package/src/lib/drives.ts +11 -0
- package/src/lib/exec.server.ts +66 -5
- package/src/lib/instances.server.ts +45 -3
- package/src/lib/log-capture.server.ts +17 -5
- package/src/lib/mcp-auth.server.ts +52 -0
- package/src/lib/picker.server.ts +23 -2
- package/src/lib/sandbox-ops.server.ts +263 -0
- package/src/mcp/routes.server.ts +25 -0
- package/src/mcp/server.server.ts +52 -0
- package/src/mcp/tools.server.ts +494 -0
- package/src/pages/Settings.svelte +12 -0
- package/src/routes.ts +63 -1
- package/src/types.ts +15 -0
- package/.mochi/svelte-client/HydratableIsland-zrdsrsmg.js +0 -1
- package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-v7y7dh53.js +0 -2
- package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-tmwd6fpb.js +0 -14
- package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-m7t64dbh.js +0 -1
- package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-4a7ann1f.js +0 -40
- package/.mochi/svelte-client/chunk-5jwfs4p9.js +0 -2
- package/.mochi/svelte-client/chunk-6qg4q98a.js +0 -2
- package/.mochi/svelte-client/chunk-tmeh70wg.js +0 -2
- package/.mochi/svelte-client/chunk-vs1yjbqt.js +0 -2
- package/.mochi/svelte-client/chunk-y7f4jbdm.js +0 -2
- package/.mochi/svelte-compile/Instance-knnkcn2p.server.js +0 -377
package/src/lib/exec.server.ts
CHANGED
|
@@ -22,6 +22,37 @@ function collector(): { stream: Writable; text: () => string } {
|
|
|
22
22
|
const STDIN_ENV = '__CODEBAY_EXEC_STDIN';
|
|
23
23
|
const STDIN_VAR = 'CODEBAY_STDIN';
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Linux rejects any single argv/env string over 128 KiB (`MAX_ARG_STRLEN`), and the stdin carrier
|
|
27
|
+
* is one env entry — so this, minus headroom for the name, is the hard ceiling on any payload.
|
|
28
|
+
*/
|
|
29
|
+
export const EXEC_STDIN_MAX_BYTES = 128 * 1024 - 1024;
|
|
30
|
+
|
|
31
|
+
/** The largest UTF-8 payload that still fits the carrier once base64 has inflated it by 4/3. */
|
|
32
|
+
export const EXEC_STDIN_MAX_BYTES_BASE64 = Math.floor((EXEC_STDIN_MAX_BYTES / 4) * 3);
|
|
33
|
+
|
|
34
|
+
/** Builds the exec target from an instance row; `container_id` must already be known non-null. */
|
|
35
|
+
export function execTargetFor(row: {
|
|
36
|
+
container_id: string | null;
|
|
37
|
+
remote_user: string | null;
|
|
38
|
+
}): ExecTarget {
|
|
39
|
+
return { containerId: row.container_id!, remoteUser: row.remote_user };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Everything after the last `marker`, or null when absent — login-shell profile noise precedes every script's own output. */
|
|
43
|
+
export function afterMarker(stdout: string, marker: string): string | null {
|
|
44
|
+
const at = stdout.lastIndexOf(marker);
|
|
45
|
+
return at === -1 ? null : stdout.slice(at + marker.length);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Just the marker's own line, untrimmed — an empty leading field is meaningful to some callers. */
|
|
49
|
+
export function markerLine(stdout: string, marker: string): string | null {
|
|
50
|
+
const rest = afterMarker(stdout, marker);
|
|
51
|
+
if (rest === null) return null;
|
|
52
|
+
const nl = rest.indexOf('\n');
|
|
53
|
+
return (nl === -1 ? rest : rest.slice(0, nl)).replace(/\r$/, '');
|
|
54
|
+
}
|
|
55
|
+
|
|
25
56
|
/**
|
|
26
57
|
* Real exec stdin is unusable on Bun — dockerode's hijack path needs an `upgrade` event
|
|
27
58
|
* Bun never emits, and `openStdin` needs full-duplex client HTTP Bun also lacks.
|
|
@@ -31,16 +62,41 @@ function wrapWithStdin(script: string): string {
|
|
|
31
62
|
return `${STDIN_VAR}="$${STDIN_ENV}"; unset ${STDIN_ENV}\n${script}`;
|
|
32
63
|
}
|
|
33
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Resolves false on timeout. A host-side backstop only: the container process keeps running, so a
|
|
67
|
+
* caller that needs the command itself killed must also wrap it in the container's own `timeout`.
|
|
68
|
+
*/
|
|
69
|
+
function streamEnded(stream: NodeJS.ReadableStream, timeoutMs?: number): Promise<boolean> {
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
const timer = timeoutMs ? setTimeout(() => resolve(false), timeoutMs) : null;
|
|
72
|
+
const settle = (fn: () => void) => {
|
|
73
|
+
if (timer) clearTimeout(timer);
|
|
74
|
+
fn();
|
|
75
|
+
};
|
|
76
|
+
stream.on('end', () => settle(() => resolve(true)));
|
|
77
|
+
stream.on('error', (e) => settle(() => reject(e)));
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
34
81
|
/**
|
|
35
82
|
* The one place the `bash -lc` container-exec pattern lives; every injection goes through it.
|
|
36
83
|
* `stdin` reaches the script as `$CODEBAY_STDIN`, and `args` as `$0`, `$1`, ….
|
|
37
84
|
*/
|
|
38
85
|
export async function execInContainer(
|
|
39
86
|
target: ExecTarget,
|
|
40
|
-
opts: { script: string; stdin?: string; args?: string[]; capture?: boolean }
|
|
87
|
+
opts: { script: string; stdin?: string; args?: string[]; capture?: boolean; timeoutMs?: number }
|
|
41
88
|
): Promise<{ ok: boolean; stdout: string; error?: string }> {
|
|
42
89
|
const user = target.remoteUser?.trim() || 'root';
|
|
43
90
|
const hasStdin = opts.stdin !== undefined;
|
|
91
|
+
const stdinBytes = hasStdin ? Buffer.byteLength(opts.stdin!, 'utf8') : 0;
|
|
92
|
+
if (stdinBytes > EXEC_STDIN_MAX_BYTES) {
|
|
93
|
+
// The kernel would fail the exec with an opaque E2BIG; say what the limit actually is.
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
stdout: '',
|
|
97
|
+
error: `payload of ${stdinBytes} bytes exceeds the ${EXEC_STDIN_MAX_BYTES}-byte limit a container exec can carry`
|
|
98
|
+
};
|
|
99
|
+
}
|
|
44
100
|
try {
|
|
45
101
|
const exec = await (await getDocker()).getContainer(target.containerId).exec({
|
|
46
102
|
Cmd: [
|
|
@@ -60,10 +116,15 @@ export async function execInContainer(
|
|
|
60
116
|
const out = collector();
|
|
61
117
|
const err = collector();
|
|
62
118
|
exec.modem.demuxStream(stream, out.stream, err.stream);
|
|
63
|
-
await
|
|
64
|
-
|
|
65
|
-
stream.
|
|
66
|
-
|
|
119
|
+
if (!(await streamEnded(stream, opts.timeoutMs))) {
|
|
120
|
+
// Otherwise the exec socket and its demux collectors live on for as long as the command does.
|
|
121
|
+
stream.destroy();
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
stdout: opts.capture ? out.text().trim() : '',
|
|
125
|
+
error: `timed out after ${opts.timeoutMs}ms`
|
|
126
|
+
};
|
|
127
|
+
}
|
|
67
128
|
|
|
68
129
|
const code = (await exec.inspect()).ExitCode ?? 1;
|
|
69
130
|
const stdout = opts.capture ? out.text().trim() : '';
|
|
@@ -18,6 +18,9 @@ import {
|
|
|
18
18
|
deleteInstanceRow,
|
|
19
19
|
getInstance,
|
|
20
20
|
getOption,
|
|
21
|
+
getRun,
|
|
22
|
+
openRunFor,
|
|
23
|
+
openRuns,
|
|
21
24
|
insertForward,
|
|
22
25
|
insertInstance,
|
|
23
26
|
listForwards,
|
|
@@ -38,6 +41,7 @@ import {
|
|
|
38
41
|
stopContainer
|
|
39
42
|
} from './docker.server.ts';
|
|
40
43
|
import {
|
|
44
|
+
AGENT_RUN_MARKER,
|
|
41
45
|
copyWorkspace,
|
|
42
46
|
devcontainerCliAvailable,
|
|
43
47
|
devcontainerUp,
|
|
@@ -67,12 +71,14 @@ import {
|
|
|
67
71
|
syncHealthMonitors
|
|
68
72
|
} from './health.server.ts';
|
|
69
73
|
import { runCapturePass, stopLogCapture, syncLogCapture } from './log-capture.server.ts';
|
|
74
|
+
import { currentRunSummaries, runSummary, setRunChangeHook, stopRun } from './agent-runs.server.ts';
|
|
70
75
|
import { isHostPortBindable, pickBindablePort } from './ports.server.ts';
|
|
71
76
|
import { pickAvatar, pickUniqueAvatar } from '../avatars/pick.server.ts';
|
|
72
77
|
import type { ServerWebSocket } from 'bun';
|
|
73
78
|
import {
|
|
74
79
|
isInstanceFilter,
|
|
75
80
|
normalizeMode,
|
|
81
|
+
type AgentRunSummary,
|
|
76
82
|
type Instance,
|
|
77
83
|
type InstanceFilter,
|
|
78
84
|
type InstanceHealth,
|
|
@@ -186,6 +192,9 @@ export function subscribeLogs(id: string, onChunk: (chunk: string) => void): ()
|
|
|
186
192
|
export type StreamEvent =
|
|
187
193
|
| { type: 'instances'; data: Instance[] }
|
|
188
194
|
| { type: 'health'; data: { id: string; health: InstanceHealth } }
|
|
195
|
+
// Its own frame rather than a list reconcile: run progress ticks every few seconds, and
|
|
196
|
+
// `reconcileInstances` runs a `docker inspect` per instance.
|
|
197
|
+
| { type: 'run'; data: AgentRunSummary }
|
|
189
198
|
| { type: 'preflight'; data: { docker: boolean; cli: boolean } }
|
|
190
199
|
// `name` is the chosen sprite, or null for the default box logo. Lets the header swap live.
|
|
191
200
|
| { type: 'pet'; data: { name: string | null } }
|
|
@@ -240,6 +249,11 @@ export function broadcastHealth(id: string, health: InstanceHealth): void {
|
|
|
240
249
|
broadcast({ type: 'health', data: { id, health } });
|
|
241
250
|
}
|
|
242
251
|
|
|
252
|
+
setRunChangeHook((runId) => {
|
|
253
|
+
const row = getRun(runId);
|
|
254
|
+
if (row) broadcast({ type: 'run', data: runSummary(row) });
|
|
255
|
+
});
|
|
256
|
+
|
|
243
257
|
/** Called from the settings action so every open dashboard swaps its header logo without a reload. */
|
|
244
258
|
export function broadcastPet(name: string | null): void {
|
|
245
259
|
broadcast({ type: 'pet', data: { name } });
|
|
@@ -301,6 +315,7 @@ export function streamOpen(ws: ServerWebSocket<unknown>): void {
|
|
|
301
315
|
// Seed everything up front so a fresh or reconnected client is correct without waiting a tick.
|
|
302
316
|
void listInstances().then((list) => sendTo(ws, { type: 'instances', data: list }));
|
|
303
317
|
for (const snap of currentHealthSnapshots()) sendTo(ws, { type: 'health', data: snap });
|
|
318
|
+
for (const run of currentRunSummaries()) sendTo(ws, { type: 'run', data: run });
|
|
304
319
|
void backgroundPreflight().then((pf) => sendTo(ws, { type: 'preflight', data: pf }));
|
|
305
320
|
// Keeps a reconnecting client correct even if the pet changed while it was away.
|
|
306
321
|
sendTo(ws, { type: 'pet', data: { name: getOption('pet') || null } });
|
|
@@ -381,7 +396,18 @@ async function boot(row: InstanceRow, opts: { branch?: string } = {}): Promise<v
|
|
|
381
396
|
} else {
|
|
382
397
|
appendLog(row.id, `Copying ${row.source_path} → ${row.workspace_path}\n`);
|
|
383
398
|
const ignore = parseCopyIgnore(getOption('copy_ignore_patterns') ?? DEFAULT_COPY_IGNORE);
|
|
384
|
-
await copyWorkspace(
|
|
399
|
+
const { dereferencedSymlinks } = await copyWorkspace(
|
|
400
|
+
row.source_path,
|
|
401
|
+
row.workspace_path,
|
|
402
|
+
ignore
|
|
403
|
+
);
|
|
404
|
+
if (dereferencedSymlinks > 0) {
|
|
405
|
+
appendLog(
|
|
406
|
+
row.id,
|
|
407
|
+
`⚠ ${dereferencedSymlinks} symlink(s) copied as regular files — Windows needs Developer Mode (or admin rights) to recreate symlinks, so edits to them no longer write through to their targets
|
|
408
|
+
`
|
|
409
|
+
);
|
|
410
|
+
}
|
|
385
411
|
appendLog(row.id, `⏱ copy: ${elapsed(sourceStart)}\n`);
|
|
386
412
|
}
|
|
387
413
|
await seedDeclaredPorts(row);
|
|
@@ -680,6 +706,8 @@ export async function listInstances(): Promise<Instance[]> {
|
|
|
680
706
|
for (const { id, health } of currentHealthSnapshots()) {
|
|
681
707
|
openPorts.set(id, new Set(health.openPorts));
|
|
682
708
|
}
|
|
709
|
+
// One query for every sandbox rather than one per row, since this runs on every reconcile tick.
|
|
710
|
+
const runs = new Map(openRuns().map((run) => [run.instance_id, runSummary(run)]));
|
|
683
711
|
const forwards = new Map<
|
|
684
712
|
string,
|
|
685
713
|
{ container_port: number; host_port: number; open: boolean }[]
|
|
@@ -700,7 +728,8 @@ export async function listInstances(): Promise<Instance[]> {
|
|
|
700
728
|
avatar: effectiveAvatarName(row),
|
|
701
729
|
git_branch: branches.get(row.id) ?? null,
|
|
702
730
|
attention: getAttention(row.id),
|
|
703
|
-
forwarded_ports: forwards.get(row.id) ?? []
|
|
731
|
+
forwarded_ports: forwards.get(row.id) ?? [],
|
|
732
|
+
active_run: runs.get(row.id) ?? null
|
|
704
733
|
}));
|
|
705
734
|
}
|
|
706
735
|
|
|
@@ -771,6 +800,9 @@ export function rebuildInstance(id: string, opts: { noCache?: boolean } = {}): I
|
|
|
771
800
|
const row = getInstance(id);
|
|
772
801
|
if (!row) throw new Error('Instance not found');
|
|
773
802
|
if (inFlight.has(id)) return row; // a build is already in flight
|
|
803
|
+
// The rebuild replaces the container the run's detached process lives in, so it can never
|
|
804
|
+
// report back — fail it now rather than leaving the poller to time it out.
|
|
805
|
+
void cancelActiveRun(id, 'the sandbox was rebuilt');
|
|
774
806
|
// Claimed before the broadcast below, which reconciles synchronously: an unclaimed `creating`
|
|
775
807
|
// row reads as orphaned, and reconcile would flip it straight back off `creating`.
|
|
776
808
|
inFlight.add(id);
|
|
@@ -807,7 +839,9 @@ function quote(value: string): string {
|
|
|
807
839
|
export async function relaunchSurface(row: InstanceRow): Promise<void> {
|
|
808
840
|
if (!row.container_id) return;
|
|
809
841
|
|
|
810
|
-
|
|
842
|
+
// No run survives a container restart, but its marker does when the container was stopped out
|
|
843
|
+
// from under it — left in place, every launcher would keep opening a plain shell instead of Claude.
|
|
844
|
+
const steps: string[] = [`rm -f "$HOME/${AGENT_RUN_MARKER}"`];
|
|
811
845
|
// The folderOpen task's run-once gate is meant to span one container run, but the marker file
|
|
812
846
|
// outlives it — left in place, a restarted IDE container never reopens the Claude terminal.
|
|
813
847
|
if (row.mode !== 'terminal') steps.push(`rm -f "$HOME/${TERMINAL_LAUNCHED_MARKER}"`);
|
|
@@ -845,9 +879,16 @@ export async function startInstance(id: string): Promise<InstanceRow> {
|
|
|
845
879
|
return getInstance(id)!;
|
|
846
880
|
}
|
|
847
881
|
|
|
882
|
+
/** Runs are per-container, so any transition that replaces or stops one has to settle them first. */
|
|
883
|
+
async function cancelActiveRun(id: string, reason: string): Promise<void> {
|
|
884
|
+
const open = openRunFor(id);
|
|
885
|
+
if (open) await stopRun(open.id, reason).catch(() => undefined);
|
|
886
|
+
}
|
|
887
|
+
|
|
848
888
|
export async function stopInstance(id: string): Promise<InstanceRow> {
|
|
849
889
|
const row = getInstance(id);
|
|
850
890
|
if (!row) throw new Error('Instance not found');
|
|
891
|
+
await cancelActiveRun(id, 'the sandbox was stopped');
|
|
851
892
|
// A later delete can't exec into a stopped container, so this is the last chance to mirror.
|
|
852
893
|
stopLogCapture(id);
|
|
853
894
|
if (row.container_id) await runCapturePass(row).catch(() => undefined);
|
|
@@ -864,6 +905,7 @@ export async function stopInstance(id: string): Promise<InstanceRow> {
|
|
|
864
905
|
export async function deleteInstance(id: string): Promise<void> {
|
|
865
906
|
const row = getInstance(id);
|
|
866
907
|
if (!row) return;
|
|
908
|
+
await cancelActiveRun(id, 'the sandbox was deleted');
|
|
867
909
|
stopHealthMonitor(id);
|
|
868
910
|
// Mirror the tail of the session before the transcripts die with the container; the periodic
|
|
869
911
|
// chain is stopped first so it can't race this last pass. Retention outlives the instance —
|
|
@@ -17,7 +17,7 @@ import { execInContainer } from './exec.server.ts';
|
|
|
17
17
|
const CAPTURE_MS = 10_000;
|
|
18
18
|
|
|
19
19
|
const MANIFEST_MARKER = '__CODEBAY_MANIFEST__';
|
|
20
|
-
const FETCH_MARKER = '__CODEBAY_FETCH__';
|
|
20
|
+
export const FETCH_MARKER = '__CODEBAY_FETCH__';
|
|
21
21
|
const FILE_MARKER = '__CODEBAY_FILE__';
|
|
22
22
|
const END_MARKER = '__CODEBAY_END__';
|
|
23
23
|
|
|
@@ -48,14 +48,26 @@ function manifestScript(): string {
|
|
|
48
48
|
);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* One framed base64 block of `pathExpr` from `offsetExpr` to EOF (or `maxBytes`) — the shape
|
|
53
|
+
* `parseFetchBlocks` reads. Both exprs are spliced into a shell script, so callers pass shell
|
|
54
|
+
* exprs, not values.
|
|
55
|
+
*/
|
|
56
|
+
export function tailBlockScript(pathExpr: string, offsetExpr: string, maxBytes?: number): string {
|
|
57
|
+
const cap = maxBytes ? ` | head -c ${maxBytes}` : '';
|
|
58
|
+
return (
|
|
59
|
+
`printf '${FILE_MARKER}\\t%s\\n' "${pathExpr}"; ` +
|
|
60
|
+
`tail -c "+${offsetExpr}" "${pathExpr}" 2>/dev/null${cap} | base64; ` +
|
|
61
|
+
`printf '\\n${END_MARKER}\\n'`
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
51
65
|
/** Emits, per requested file, a framed block of base64 bytes from `startByte` to EOF. */
|
|
52
66
|
function fetchScript(): string {
|
|
53
67
|
return (
|
|
54
68
|
`printf '%s\\n' '${FETCH_MARKER}'; ` +
|
|
55
69
|
`while [ "$#" -ge 2 ]; do p="$1"; o="$2"; shift 2; ` +
|
|
56
|
-
|
|
57
|
-
`tail -c "+$o" "$p" 2>/dev/null | base64; ` +
|
|
58
|
-
`printf '\\n${END_MARKER}\\n'; done`
|
|
70
|
+
`${tailBlockScript('$p', '$o')}; done`
|
|
59
71
|
);
|
|
60
72
|
}
|
|
61
73
|
|
|
@@ -118,7 +130,7 @@ export function parseFetchBlocks(stdout: string): { path: string; base64: string
|
|
|
118
130
|
return blocks;
|
|
119
131
|
}
|
|
120
132
|
|
|
121
|
-
function sizeOnDisk(path: string): number {
|
|
133
|
+
export function sizeOnDisk(path: string): number {
|
|
122
134
|
try {
|
|
123
135
|
return statSync(path).size;
|
|
124
136
|
} catch {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { getOption, setOption } from './db.server.ts';
|
|
3
|
+
import { timingSafeEqualStr } from './crypto.server.ts';
|
|
4
|
+
|
|
5
|
+
/** The MCP endpoint's path; `auth.server.ts` and the CSRF filter both key off this exact value. */
|
|
6
|
+
export const MCP_PATH = '/mcp';
|
|
7
|
+
|
|
8
|
+
export const MCP_ENABLED_KEY = 'mcp_enabled';
|
|
9
|
+
export const MCP_TOKEN_KEY = 'mcp_token';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Off until someone opts in. The token gates the endpoint, but an install that never wanted an
|
|
13
|
+
* agent-facing control plane shouldn't expose one at all.
|
|
14
|
+
*/
|
|
15
|
+
export function mcpEnabled(): boolean {
|
|
16
|
+
return getOption(MCP_ENABLED_KEY) === '1';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function setMcpEnabled(enabled: boolean): void {
|
|
20
|
+
setOption(MCP_ENABLED_KEY, enabled ? '1' : '0');
|
|
21
|
+
if (enabled) getMcpToken();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function mint(): string {
|
|
25
|
+
return `cb_${randomBytes(32).toString('base64url')}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Minted on first read and persisted, so the settings page and the route can't disagree. Unlike
|
|
30
|
+
* every other secret here this one is shown to the user in plaintext — copying it into an MCP
|
|
31
|
+
* client is the whole point of it existing.
|
|
32
|
+
*/
|
|
33
|
+
export function getMcpToken(): string {
|
|
34
|
+
const existing = getOption(MCP_TOKEN_KEY);
|
|
35
|
+
if (existing) return existing;
|
|
36
|
+
const token = mint();
|
|
37
|
+
setOption(MCP_TOKEN_KEY, token);
|
|
38
|
+
return token;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function regenerateMcpToken(): string {
|
|
42
|
+
const token = mint();
|
|
43
|
+
setOption(MCP_TOKEN_KEY, token);
|
|
44
|
+
return token;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Constant-time, like the bridge token check, so a wrong guess leaks nothing by timing. */
|
|
48
|
+
export function mcpAuthOk(request: Request): boolean {
|
|
49
|
+
const header = request.headers.get('authorization');
|
|
50
|
+
if (!header?.startsWith('Bearer ')) return false;
|
|
51
|
+
return timingSafeEqualStr(header.slice(7).trim(), getMcpToken());
|
|
52
|
+
}
|
package/src/lib/picker.server.ts
CHANGED
|
@@ -1,17 +1,30 @@
|
|
|
1
1
|
import { readdir, stat } from 'node:fs/promises';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
-
import { dirname, join } from 'node:path';
|
|
4
|
+
import { dirname, join, sep } from 'node:path';
|
|
5
5
|
import { getOption, setOption } from './db.server';
|
|
6
6
|
import { findDevcontainerConfig } from './devcontainer.server.ts';
|
|
7
|
+
import { DRIVES_ROOT } from './drives.ts';
|
|
7
8
|
import type { BrowseResult, DirEntry } from '../types.ts';
|
|
8
9
|
|
|
9
10
|
const LAST_VIEWED_FOLDER = 'last_viewed_folder';
|
|
10
11
|
|
|
12
|
+
const isWindows = process.platform === 'win32';
|
|
13
|
+
|
|
11
14
|
function hasDevcontainer(dir: string): boolean {
|
|
12
15
|
return findDevcontainerConfig(dir) !== null;
|
|
13
16
|
}
|
|
14
17
|
|
|
18
|
+
/** Probed rather than enumerated, so listing the drives needs no shell-out. */
|
|
19
|
+
function driveRoots(): DirEntry[] {
|
|
20
|
+
const roots: DirEntry[] = [];
|
|
21
|
+
for (let i = 0; i < 26; i++) {
|
|
22
|
+
const path = `${String.fromCharCode(65 + i)}:${sep}`;
|
|
23
|
+
if (existsSync(path)) roots.push({ name: path, path, hasDevcontainer: hasDevcontainer(path) });
|
|
24
|
+
}
|
|
25
|
+
return roots;
|
|
26
|
+
}
|
|
27
|
+
|
|
15
28
|
/** Persists every folder it lists, so an argument-less call resumes where the user left off. */
|
|
16
29
|
export async function browse(path?: string): Promise<BrowseResult> {
|
|
17
30
|
let target: string;
|
|
@@ -22,6 +35,12 @@ export async function browse(path?: string): Promise<BrowseResult> {
|
|
|
22
35
|
target = saved && existsSync(saved) ? saved : homedir();
|
|
23
36
|
}
|
|
24
37
|
|
|
38
|
+
// Deliberately not persisted as the last-viewed folder: it is not a real directory, so a
|
|
39
|
+
// resume would fall through to the home dir anyway.
|
|
40
|
+
if (isWindows && target === DRIVES_ROOT) {
|
|
41
|
+
return { path: DRIVES_ROOT, hasDevcontainer: false, parent: null, entries: driveRoots() };
|
|
42
|
+
}
|
|
43
|
+
|
|
25
44
|
const info = await stat(target);
|
|
26
45
|
if (!info.isDirectory()) throw new Error(`Not a folder: ${target}`);
|
|
27
46
|
|
|
@@ -40,7 +59,9 @@ export async function browse(path?: string): Promise<BrowseResult> {
|
|
|
40
59
|
return {
|
|
41
60
|
path: target,
|
|
42
61
|
hasDevcontainer: hasDevcontainer(target),
|
|
43
|
-
|
|
62
|
+
// A Windows drive root is its own dirname, which would otherwise strand the user on
|
|
63
|
+
// whichever drive their home directory sits on.
|
|
64
|
+
parent: parent === target ? (isWindows ? DRIVES_ROOT : null) : parent,
|
|
44
65
|
entries
|
|
45
66
|
};
|
|
46
67
|
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EXEC_STDIN_MAX_BYTES_BASE64,
|
|
3
|
+
afterMarker,
|
|
4
|
+
execInContainer,
|
|
5
|
+
execTargetFor,
|
|
6
|
+
markerLine
|
|
7
|
+
} from './exec.server.ts';
|
|
8
|
+
import { SOURCE_INJECTED_ENV } from './devcontainer.server.ts';
|
|
9
|
+
import { getOption, type InstanceRow } from './db.server.ts';
|
|
10
|
+
import { HOME_PRELUDE, shellSingleQuote as quote } from './container-files.server.ts';
|
|
11
|
+
|
|
12
|
+
/** Enough for a big diff or a test run's output, small enough not to blow a caller's context. */
|
|
13
|
+
export const OUTPUT_CAP = 200_000;
|
|
14
|
+
|
|
15
|
+
/** What `write_file` can carry in one call: the content rides base64'd through the exec env carrier. */
|
|
16
|
+
export const WRITE_FILE_MAX_BYTES = EXEC_STDIN_MAX_BYTES_BASE64;
|
|
17
|
+
|
|
18
|
+
const CODE_MARKER = '__CODEBAY_CMD__';
|
|
19
|
+
const OUT_MARKER = '__CODEBAY_CMD_OUT__';
|
|
20
|
+
const ERR_MARKER = '__CODEBAY_CMD_ERR__';
|
|
21
|
+
const END_MARKER = '__CODEBAY_CMD_END__';
|
|
22
|
+
|
|
23
|
+
/** `timeout`'s own exit code for a command it had to kill. */
|
|
24
|
+
const TIMEOUT_EXIT = 124;
|
|
25
|
+
|
|
26
|
+
export interface CommandResult {
|
|
27
|
+
exitCode: number;
|
|
28
|
+
stdout: string;
|
|
29
|
+
stderr: string;
|
|
30
|
+
timedOut: boolean;
|
|
31
|
+
truncated: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Everything here runs from the workspace, since an exec only inherits the image's WorkingDir. */
|
|
35
|
+
function workspacePrelude(row: InstanceRow): string {
|
|
36
|
+
const cd = row.remote_workspace_folder
|
|
37
|
+
? `cd ${quote(row.remote_workspace_folder)} || exit 1; `
|
|
38
|
+
: '';
|
|
39
|
+
return `${HOME_PRELUDE}[ -n "$HOME" ] || export HOME="$h"; ${cd}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function decodeBlock(stdout: string, marker: string): string {
|
|
43
|
+
const rest = afterMarker(stdout, marker);
|
|
44
|
+
if (rest === null) return '';
|
|
45
|
+
const end = rest.indexOf(END_MARKER);
|
|
46
|
+
// GNU base64 wraps at 76 columns, and a trimmed capture can drop the final newline.
|
|
47
|
+
const b64 = (end === -1 ? rest : rest.slice(0, end)).replace(/\s+/g, '');
|
|
48
|
+
return b64 ? Buffer.from(b64, 'base64').toString('utf8') : '';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function cap(text: string): { text: string; truncated: boolean } {
|
|
52
|
+
return text.length > OUTPUT_CAP
|
|
53
|
+
? { text: text.slice(0, OUTPUT_CAP) + '\n… output truncated', truncated: true }
|
|
54
|
+
: { text, truncated: false };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The command rides in through the stdin carrier rather than the script body, so nothing has to be
|
|
59
|
+
* escaped and it never reaches the container's process list; output is base64'd because the
|
|
60
|
+
* capture path trims and the demuxed stream is text.
|
|
61
|
+
*/
|
|
62
|
+
export async function execCommand(
|
|
63
|
+
row: InstanceRow,
|
|
64
|
+
command: string,
|
|
65
|
+
opts: { timeoutSeconds?: number; cwd?: string } = {}
|
|
66
|
+
): Promise<CommandResult> {
|
|
67
|
+
const seconds = Math.max(1, Math.min(opts.timeoutSeconds ?? 120, 3600));
|
|
68
|
+
const script =
|
|
69
|
+
workspacePrelude(row) +
|
|
70
|
+
SOURCE_INJECTED_ENV +
|
|
71
|
+
`o=$(mktemp); e=$(mktemp); ` +
|
|
72
|
+
// The in-container `timeout` is what actually kills the process; execInContainer's own
|
|
73
|
+
// timeoutMs below is only a backstop for a wedged docker stream.
|
|
74
|
+
`if command -v timeout >/dev/null 2>&1; then timeout -k 5 ${seconds} bash -c "$CODEBAY_STDIN" >"$o" 2>"$e"; ` +
|
|
75
|
+
`else bash -c "$CODEBAY_STDIN" >"$o" 2>"$e"; fi; c=$?; ` +
|
|
76
|
+
`printf '${CODE_MARKER}%s\\n' "$c"; ` +
|
|
77
|
+
`printf '${OUT_MARKER}\\n'; base64 < "$o"; printf '\\n${END_MARKER}\\n'; ` +
|
|
78
|
+
`printf '${ERR_MARKER}\\n'; base64 < "$e"; printf '\\n${END_MARKER}\\n'; ` +
|
|
79
|
+
`rm -f "$o" "$e"; true`;
|
|
80
|
+
|
|
81
|
+
const res = await execInContainer(execTargetFor(row), {
|
|
82
|
+
script,
|
|
83
|
+
stdin: opts.cwd ? `cd ${quote(opts.cwd)} || exit 1\n${command}` : command,
|
|
84
|
+
capture: true,
|
|
85
|
+
timeoutMs: (seconds + 15) * 1000
|
|
86
|
+
});
|
|
87
|
+
if (!res.ok) {
|
|
88
|
+
return {
|
|
89
|
+
exitCode: -1,
|
|
90
|
+
stdout: '',
|
|
91
|
+
stderr: res.error ?? 'the command could not be run',
|
|
92
|
+
timedOut: false,
|
|
93
|
+
truncated: false
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const exitCode = Number.parseInt(markerLine(res.stdout, CODE_MARKER) ?? '', 10);
|
|
98
|
+
const out = cap(decodeBlock(res.stdout, OUT_MARKER));
|
|
99
|
+
const err = cap(decodeBlock(res.stdout, ERR_MARKER));
|
|
100
|
+
return {
|
|
101
|
+
exitCode: Number.isFinite(exitCode) ? exitCode : -1,
|
|
102
|
+
stdout: out.text,
|
|
103
|
+
stderr: err.text,
|
|
104
|
+
timedOut: exitCode === TIMEOUT_EXIT,
|
|
105
|
+
truncated: out.truncated || err.truncated
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Throws the command's own stderr, so a tool caller sees why git or gh refused. */
|
|
110
|
+
async function must(row: InstanceRow, command: string, timeoutSeconds = 120): Promise<string> {
|
|
111
|
+
const res = await execCommand(row, command, { timeoutSeconds });
|
|
112
|
+
if (res.exitCode !== 0) {
|
|
113
|
+
throw new Error((res.stderr || res.stdout || `command failed (exit ${res.exitCode})`).trim());
|
|
114
|
+
}
|
|
115
|
+
return res.stdout;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The path arrives as an argv entry rather than spliced into the script, so no amount of quoting
|
|
120
|
+
* in a caller-supplied filename can escape into the shell.
|
|
121
|
+
*/
|
|
122
|
+
export async function readWorkspaceFile(row: InstanceRow, path: string): Promise<string> {
|
|
123
|
+
const script =
|
|
124
|
+
workspacePrelude(row) +
|
|
125
|
+
`if [ ! -f "$1" ]; then printf '${CODE_MARKER}1\\n'; exit 0; fi; ` +
|
|
126
|
+
`printf '${CODE_MARKER}0\\n'; printf '${OUT_MARKER}\\n'; base64 < "$1"; printf '\\n${END_MARKER}\\n'`;
|
|
127
|
+
const res = await execInContainer(execTargetFor(row), {
|
|
128
|
+
script,
|
|
129
|
+
args: ['read-file', path],
|
|
130
|
+
capture: true,
|
|
131
|
+
timeoutMs: 30_000
|
|
132
|
+
});
|
|
133
|
+
if (!res.ok) throw new Error(res.error ?? 'could not read the file');
|
|
134
|
+
if (res.stdout.includes(`${CODE_MARKER}1`)) throw new Error(`no such file: ${path}`);
|
|
135
|
+
return cap(decodeBlock(res.stdout, OUT_MARKER)).text;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function writeWorkspaceFile(
|
|
139
|
+
row: InstanceRow,
|
|
140
|
+
path: string,
|
|
141
|
+
content: string
|
|
142
|
+
): Promise<void> {
|
|
143
|
+
const bytes = Buffer.byteLength(content, 'utf8');
|
|
144
|
+
if (bytes > WRITE_FILE_MAX_BYTES) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`content is ${bytes} bytes; write_file carries at most ${WRITE_FILE_MAX_BYTES} — split it, or ` +
|
|
147
|
+
'build the file inside the sandbox with exec_command'
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
const script =
|
|
151
|
+
workspacePrelude(row) +
|
|
152
|
+
`set -e; mkdir -p "$(dirname "$1")"; printf '%s' "$CODEBAY_STDIN" | base64 -d > "$1"`;
|
|
153
|
+
const res = await execInContainer(execTargetFor(row), {
|
|
154
|
+
script,
|
|
155
|
+
args: ['write-file', path],
|
|
156
|
+
stdin: Buffer.from(content, 'utf8').toString('base64'),
|
|
157
|
+
timeoutMs: 30_000
|
|
158
|
+
});
|
|
159
|
+
if (!res.ok) throw new Error(res.error ?? 'could not write the file');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface DiffResult {
|
|
163
|
+
diff: string;
|
|
164
|
+
status: string;
|
|
165
|
+
branch: string;
|
|
166
|
+
truncated: boolean;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** `--stat`-free full patch plus a porcelain summary, which is what an agent usually wants next. */
|
|
170
|
+
export async function gitDiff(
|
|
171
|
+
row: InstanceRow,
|
|
172
|
+
opts: { base?: string; staged?: boolean } = {}
|
|
173
|
+
): Promise<DiffResult> {
|
|
174
|
+
const range = opts.base ? ` ${quote(opts.base)}...HEAD` : opts.staged ? ' --staged' : '';
|
|
175
|
+
const [diff, status, branch] = await Promise.all([
|
|
176
|
+
execCommand(row, `git --no-pager diff${range}`),
|
|
177
|
+
execCommand(row, 'git status --short'),
|
|
178
|
+
execCommand(row, 'git rev-parse --abbrev-ref HEAD')
|
|
179
|
+
]);
|
|
180
|
+
if (diff.exitCode !== 0) throw new Error((diff.stderr || 'git diff failed').trim());
|
|
181
|
+
return {
|
|
182
|
+
diff: diff.stdout,
|
|
183
|
+
status: status.stdout,
|
|
184
|
+
branch: branch.stdout.trim(),
|
|
185
|
+
truncated: diff.truncated
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface PushResult {
|
|
190
|
+
branch: string;
|
|
191
|
+
committed: boolean;
|
|
192
|
+
output: string;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The `github-credentials` injection has already authed git and `gh` inside the container, so this
|
|
197
|
+
* is a plain push — nothing here handles credentials.
|
|
198
|
+
*/
|
|
199
|
+
export async function gitPush(
|
|
200
|
+
row: InstanceRow,
|
|
201
|
+
opts: { branch?: string; commitMessage?: string; force?: boolean } = {}
|
|
202
|
+
): Promise<PushResult> {
|
|
203
|
+
const current = (await must(row, 'git rev-parse --abbrev-ref HEAD')).trim();
|
|
204
|
+
const branch = opts.branch?.trim() || current;
|
|
205
|
+
if (!branch || branch === 'HEAD') {
|
|
206
|
+
throw new Error('the sandbox is on a detached HEAD — pass an explicit branch');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let committed = false;
|
|
210
|
+
if (opts.commitMessage) {
|
|
211
|
+
const dirty = await execCommand(row, 'git status --porcelain');
|
|
212
|
+
if (dirty.stdout.trim()) {
|
|
213
|
+
await must(row, `git add -A && git commit -m ${quote(opts.commitMessage)}`);
|
|
214
|
+
committed = true;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (current !== branch) await must(row, `git checkout -B ${quote(branch)}`);
|
|
219
|
+
|
|
220
|
+
// --force-with-lease rather than --force: it still refuses to discard commits we never saw.
|
|
221
|
+
const force = opts.force ? ' --force-with-lease' : '';
|
|
222
|
+
const output = await must(
|
|
223
|
+
row,
|
|
224
|
+
`git push${force} -u origin HEAD:refs/heads/${quote(branch)} 2>&1`,
|
|
225
|
+
300
|
|
226
|
+
);
|
|
227
|
+
return { branch, committed, output: output.trim() };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export const PR_ATTRIBUTION_KEY = 'mcp_pr_attribution';
|
|
231
|
+
|
|
232
|
+
/** The footer appended to an MCP-opened PR body when the setting is on. */
|
|
233
|
+
export const PR_ATTRIBUTION =
|
|
234
|
+
'🤖 Written by Claude Code in a [Codebay](https://github.com/khromov/codebay) sandbox, driven over MCP.';
|
|
235
|
+
|
|
236
|
+
/** Off unless explicitly enabled — an attribution line on someone's PR is theirs to opt into. */
|
|
237
|
+
export function prAttributionEnabled(): boolean {
|
|
238
|
+
return getOption(PR_ATTRIBUTION_KEY) === '1';
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Appends the footer, unless the body already carries it (a caller may have written its own). */
|
|
242
|
+
export function withAttribution(body: string): string {
|
|
243
|
+
if (!prAttributionEnabled() || body.includes(PR_ATTRIBUTION)) return body;
|
|
244
|
+
return `${body.trimEnd()}\n\n---\n${PR_ATTRIBUTION}\n`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function createPr(
|
|
248
|
+
row: InstanceRow,
|
|
249
|
+
opts: { title: string; body?: string; base?: string; draft?: boolean }
|
|
250
|
+
): Promise<{ url: string }> {
|
|
251
|
+
const parts = [
|
|
252
|
+
'gh pr create',
|
|
253
|
+
`--title ${quote(opts.title)}`,
|
|
254
|
+
`--body ${quote(withAttribution(opts.body ?? ''))}`
|
|
255
|
+
];
|
|
256
|
+
if (opts.base) parts.push(`--base ${quote(opts.base)}`);
|
|
257
|
+
if (opts.draft) parts.push('--draft');
|
|
258
|
+
const out = await must(row, `${parts.join(' ')} 2>&1`, 300);
|
|
259
|
+
// gh prints the PR URL as its last line; anything else is progress chatter.
|
|
260
|
+
const url = out.trim().split('\n').filter(Boolean).at(-1) ?? '';
|
|
261
|
+
if (!url.startsWith('http')) throw new Error(out.trim() || 'gh did not return a PR url');
|
|
262
|
+
return { url };
|
|
263
|
+
}
|