@sealant/mend 0.20.0 → 0.21.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/dist/agent-share.js +150 -0
- package/dist/dashboard.js +22 -2
- package/dist/help.js +21 -8
- package/dist/main.js +120 -126
- package/dist/shared.js +33 -16
- package/man/mend-accounts.1 +1 -1
- package/man/mend-adopt.1 +3 -3
- package/man/mend-attach.1 +1 -1
- package/man/mend-codex.1 +1 -1
- package/man/mend-completions.1 +1 -1
- package/man/mend-connect.1 +1 -1
- package/man/mend-continue.1 +1 -1
- package/man/mend-doctor.1 +1 -1
- package/man/mend-dotfiles-sync.1 +1 -1
- package/man/mend-dotfiles.1 +1 -1
- package/man/mend-env-cluster.1 +1 -1
- package/man/mend-env-load.1 +1 -1
- package/man/mend-env-show.1 +1 -1
- package/man/mend-help.1 +1 -1
- package/man/mend-keys-init.1 +4 -4
- package/man/mend-keys-mode.1 +11 -0
- package/man/mend-keys-share.1 +4 -2
- package/man/mend-keys-show.1 +3 -3
- package/man/mend-login.1 +1 -1
- package/man/mend-logout.1 +1 -1
- package/man/mend-man.1 +1 -1
- package/man/mend-pair.1 +1 -1
- package/man/mend-projects.1 +1 -1
- package/man/mend-refresh.1 +1 -1
- package/man/mend-rejoin.1 +1 -1
- package/man/mend-resume.1 +1 -1
- package/man/mend-run.1 +1 -1
- package/man/mend-service-add.1 +1 -1
- package/man/mend-service-connect.1 +1 -1
- package/man/mend-service-init.1 +1 -1
- package/man/mend-service-list.1 +1 -1
- package/man/mend-service-logs.1 +1 -1
- package/man/mend-service-restart.1 +1 -1
- package/man/mend-service-run.1 +1 -1
- package/man/mend-service-stop.1 +1 -1
- package/man/mend-sessions.1 +1 -1
- package/man/mend-shell.1 +1 -1
- package/man/mend-skills-push.1 +1 -1
- package/man/mend-skills.1 +1 -1
- package/man/mend-ssh-setup.1 +1 -1
- package/man/mend-ssh.1 +1 -1
- package/man/mend-stop.1 +1 -1
- package/man/mend-ui.1 +1 -1
- package/man/mend-version.1 +1 -1
- package/man/mend-worktrees.1 +1 -1
- package/man/mend.1 +7 -4
- package/package.json +1 -1
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import * as net from "node:net";
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
+
/** The bridge endpoint for a server url: ws(s), this machine named, the token as a query. */
|
|
4
|
+
export const bridgeUrlOf = (serverUrl, token, host) => {
|
|
5
|
+
const url = new URL(`${serverUrl}/api/keys/bridge/ws`);
|
|
6
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
7
|
+
url.searchParams.set("host", host);
|
|
8
|
+
if (token !== null)
|
|
9
|
+
url.searchParams.set("token", token);
|
|
10
|
+
return url;
|
|
11
|
+
};
|
|
12
|
+
/** Reconnect backoff: 1s, 2s, … capped at 30s. */
|
|
13
|
+
export const retryDelayMs = (attempt) => Math.min(30_000, 1000 * 2 ** Math.min(Math.max(attempt - 1, 0), 5));
|
|
14
|
+
const SSH_AGENTC_REQUEST_IDENTITIES = 11;
|
|
15
|
+
const SSH_AGENTC_SIGN_REQUEST = 13;
|
|
16
|
+
/**
|
|
17
|
+
* One complete agent exchange against the local agent: fresh connection,
|
|
18
|
+
* write the framed request verbatim, read one framed response. A hardware
|
|
19
|
+
* key blocks here until the touch — hence the generous timeout.
|
|
20
|
+
*/
|
|
21
|
+
const askLocalAgent = (agentSock, payload) => new Promise((resolve, reject) => {
|
|
22
|
+
const connection = net.connect(agentSock, () => {
|
|
23
|
+
connection.write(payload);
|
|
24
|
+
});
|
|
25
|
+
const timer = setTimeout(() => {
|
|
26
|
+
connection.destroy();
|
|
27
|
+
reject(new Error("the agent did not answer within 60s — touch missed?"));
|
|
28
|
+
}, 60_000);
|
|
29
|
+
let pending = Buffer.alloc(0);
|
|
30
|
+
connection.on("data", (chunk) => {
|
|
31
|
+
pending = Buffer.concat([pending, chunk]);
|
|
32
|
+
if (pending.length >= 4 && pending.length >= 4 + pending.readUInt32BE(0)) {
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
connection.end();
|
|
35
|
+
resolve(pending.subarray(0, 4 + pending.readUInt32BE(0)));
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
connection.on("error", (error) => {
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
reject(error);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
/** An abortable wait; an abort simply ends it early. */
|
|
44
|
+
const sleep = (ms, signal) => delay(ms, undefined, { signal }).then(() => undefined, () => undefined);
|
|
45
|
+
/** Share until the signal aborts; every connect, drop, and signature is an event. */
|
|
46
|
+
export const shareAgent = async (options) => {
|
|
47
|
+
const { url, agentSock, signal, onEvent } = options;
|
|
48
|
+
// Requests are answered one at a time: the agent protocol has one in flight.
|
|
49
|
+
let chain = Promise.resolve();
|
|
50
|
+
let attempt = 0;
|
|
51
|
+
while (!signal.aborted) {
|
|
52
|
+
const ws = new WebSocket(url);
|
|
53
|
+
const onAbort = () => ws.close();
|
|
54
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
55
|
+
const closed = new Promise((resolve) => {
|
|
56
|
+
ws.addEventListener("close", () => resolve(), { once: true });
|
|
57
|
+
});
|
|
58
|
+
const opened = await new Promise((resolve) => {
|
|
59
|
+
ws.addEventListener("open", () => resolve(true), { once: true });
|
|
60
|
+
ws.addEventListener("error", () => resolve(false), { once: true });
|
|
61
|
+
});
|
|
62
|
+
if (opened) {
|
|
63
|
+
attempt = 0;
|
|
64
|
+
onEvent({ kind: "connected" });
|
|
65
|
+
ws.addEventListener("message", (event) => {
|
|
66
|
+
const text = typeof event.data === "string"
|
|
67
|
+
? event.data
|
|
68
|
+
: Buffer.from(event.data).toString("utf8");
|
|
69
|
+
let frame;
|
|
70
|
+
try {
|
|
71
|
+
frame = JSON.parse(text);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (frame.t !== "req" ||
|
|
77
|
+
typeof frame.id !== "number" ||
|
|
78
|
+
typeof frame.payload !== "string") {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const id = frame.id;
|
|
82
|
+
const payload = Buffer.from(frame.payload, "base64");
|
|
83
|
+
const type = payload[4];
|
|
84
|
+
const context = typeof frame.context === "string" ? frame.context : "mend";
|
|
85
|
+
chain = chain.then(async () => {
|
|
86
|
+
const started = Date.now();
|
|
87
|
+
if (type === SSH_AGENTC_SIGN_REQUEST)
|
|
88
|
+
onEvent({ kind: "sign-requested", context });
|
|
89
|
+
else if (type === SSH_AGENTC_REQUEST_IDENTITIES) {
|
|
90
|
+
onEvent({ kind: "identities-requested", context });
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const response = await askLocalAgent(agentSock, payload);
|
|
94
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
95
|
+
return null;
|
|
96
|
+
ws.send(JSON.stringify({ t: "res", id, payload: response.toString("base64") }));
|
|
97
|
+
if (type === SSH_AGENTC_SIGN_REQUEST) {
|
|
98
|
+
onEvent({ kind: "signed", context, seconds: (Date.now() - started) / 1000 });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
103
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
104
|
+
ws.send(JSON.stringify({ t: "err", id, message }));
|
|
105
|
+
}
|
|
106
|
+
if (type === SSH_AGENTC_SIGN_REQUEST)
|
|
107
|
+
onEvent({ kind: "not-signed", context, message });
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
await closed;
|
|
114
|
+
signal.removeEventListener("abort", onAbort);
|
|
115
|
+
if (signal.aborted)
|
|
116
|
+
return;
|
|
117
|
+
attempt += 1;
|
|
118
|
+
const retryMs = retryDelayMs(attempt);
|
|
119
|
+
onEvent({ kind: "disconnected", retryMs });
|
|
120
|
+
await sleep(retryMs, signal);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
/** Start sharing in the background; `stop` ends it. */
|
|
124
|
+
export const startAgentShare = (options) => {
|
|
125
|
+
const controller = new AbortController();
|
|
126
|
+
const listeners = new Set();
|
|
127
|
+
let state = "connecting";
|
|
128
|
+
void shareAgent({
|
|
129
|
+
...options,
|
|
130
|
+
signal: controller.signal,
|
|
131
|
+
onEvent: (event) => {
|
|
132
|
+
if (event.kind === "connected")
|
|
133
|
+
state = "connected";
|
|
134
|
+
else if (event.kind === "disconnected")
|
|
135
|
+
state = "connecting";
|
|
136
|
+
for (const listener of listeners)
|
|
137
|
+
listener(event);
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
return {
|
|
141
|
+
snapshot: () => state,
|
|
142
|
+
subscribe: (listener) => {
|
|
143
|
+
listeners.add(listener);
|
|
144
|
+
return () => {
|
|
145
|
+
listeners.delete(listener);
|
|
146
|
+
};
|
|
147
|
+
},
|
|
148
|
+
stop: () => controller.abort(),
|
|
149
|
+
};
|
|
150
|
+
};
|
package/dist/dashboard.js
CHANGED
|
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "@opentui/reac
|
|
|
2
2
|
import { CliRenderEvents, createCliRenderer } from "@opentui/core";
|
|
3
3
|
import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
|
|
4
4
|
import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient, } from "@tanstack/react-query";
|
|
5
|
-
import { useEffect, useRef, useState } from "react";
|
|
5
|
+
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
6
6
|
import { deriveHarnesses, deriveProjects, advanceFromBase, deriveRows, deriveWorktrees, filterBranches, foldGroupStatus, rowKeyOf, worktreeDisplayName, fetchWorkbench, liveProtocolOf, liveShellOf, mapWorkbenchSessions, markSessionStopped, removeWorktreeGroup, prependSession, removeSession, replaceSession, WORKBENCH_KEY, } from "./dashboard-model.js";
|
|
7
7
|
import { reviewTargetForSession } from "./review-workflow.js";
|
|
8
8
|
import { ReviewScreen } from "./review.js";
|
|
@@ -124,6 +124,8 @@ const keepSpanVisible = (scroll, top, height) => {
|
|
|
124
124
|
};
|
|
125
125
|
const keepRowVisible = (scroll, index) => keepSpanVisible(scroll, index, 1);
|
|
126
126
|
const PROJECTS_PANE_WIDTH = 30;
|
|
127
|
+
/** The header's share fact when no share runs. */
|
|
128
|
+
const noShare = () => "off";
|
|
127
129
|
const App = ({ ctx, onQuit }) => {
|
|
128
130
|
const renderer = useRenderer();
|
|
129
131
|
const queryClient = useQueryClient();
|
|
@@ -143,6 +145,7 @@ const App = ({ ctx, onQuit }) => {
|
|
|
143
145
|
const [busy, setBusy] = useState(null);
|
|
144
146
|
const [busyStarted, setBusyStarted] = useState(0);
|
|
145
147
|
const [status, setStatus] = useState(null);
|
|
148
|
+
const shareState = useSyncExternalStore((onChange) => ctx.agentShare?.subscribe(() => onChange()) ?? (() => { }), ctx.agentShare === null ? noShare : ctx.agentShare.snapshot);
|
|
146
149
|
const [editing, setEditing] = useState(null);
|
|
147
150
|
/** Session id a stop is armed against; the second press fires it. */
|
|
148
151
|
const [stopArmed, setStopArmed] = useState(null);
|
|
@@ -197,6 +200,23 @@ const App = ({ ctx, onQuit }) => {
|
|
|
197
200
|
const selectSession = (id) => setSessionKey(`s:${id}`);
|
|
198
201
|
const say = (text) => setStatus({ text, at: Date.now() });
|
|
199
202
|
const refetch = () => void queryClient.invalidateQueries({ queryKey: WORKBENCH_KEY });
|
|
203
|
+
// Signature requests are facts from outside React — the status line says them.
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
const share = ctx.agentShare;
|
|
206
|
+
if (share === null)
|
|
207
|
+
return;
|
|
208
|
+
return share.subscribe((event) => {
|
|
209
|
+
if (event === null)
|
|
210
|
+
return;
|
|
211
|
+
if (event.kind === "sign-requested") {
|
|
212
|
+
say(`✎ signature requested (${event.context}) — touch your key if it blinks`);
|
|
213
|
+
}
|
|
214
|
+
else if (event.kind === "signed")
|
|
215
|
+
say(`✓ signed (${event.seconds.toFixed(1)}s)`);
|
|
216
|
+
else if (event.kind === "not-signed")
|
|
217
|
+
say(`✗ not signed — ${event.message}`);
|
|
218
|
+
});
|
|
219
|
+
}, [ctx.agentShare]);
|
|
200
220
|
// A repo underfoot that the store has never met: raise the adopt offer the
|
|
201
221
|
// first time the workbench answers and no project matches the cwd.
|
|
202
222
|
useEffect(() => {
|
|
@@ -1051,7 +1071,7 @@ const App = ({ ctx, onQuit }) => {
|
|
|
1051
1071
|
refetch();
|
|
1052
1072
|
}, onQuit: onQuit }));
|
|
1053
1073
|
}
|
|
1054
|
-
return (_jsxs("box", { flexGrow: 1, flexDirection: "column", backgroundColor: "transparent", children: [_jsxs("box", { height: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: INK, children: " mend" }), _jsxs("span", { fg: MUTED, children: [" ", projectItems.length, " project", projectItems.length === 1 ? "" : "s"] }), _jsx("span", { fg: FAINT, children: " \u00B7 " }), _jsxs("span", { fg: liveTotal > 0 ? MUTED : FAINT, children: [liveTotal, " live"] })] }), _jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: `${ctx.config.url} ` })] }), _jsxs("box", { flexGrow: 1, flexShrink: 1, minHeight: 0, flexDirection: "row", children: [showProjectsPane ? (_jsx(Pane, { title: "projects", focused: focus === "projects", width: PROJECTS_PANE_WIDTH, children: _jsxs("scrollbox", { ref: projectScrollRef, flexGrow: 1, flexShrink: 1, minHeight: 0, style: paneScrollStyle, children: [projectItems.map((item, index) => (_jsx(ProjectRow, { item: item, selected: index === projectIndex, nameWidth: nameWidth }, item.project.id))), data !== undefined && projectItems.length === 0 ? (_jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: " none adopted yet" })) : null] }) })) : null, _jsx(Pane, { title: sessionsTitle, focused: focus === "sessions", grow: true, children: _jsxs("scrollbox", { ref: sessionScrollRef, flexGrow: 1, flexShrink: 1, minHeight: 0, style: paneScrollStyle, children: [rows.map((row, index) => row.kind === "worktree" ? (_jsx(WorktreeHeaderRow, { group: row.group, selected: index === rowIndex }, rowKeyOf(row))) : (_jsx(SessionChildRow, { item: row.item, selected: index === rowIndex }, rowKeyOf(row)))), data !== undefined && rows.length === 0 ? (_jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: " no sessions yet — n starts one" })) : null, loadFailure !== null ? (_jsx("text", { height: 1, fg: INK_2, bg: "transparent", children: ` ${loadFailure} — retrying` })) : null] }) })] }), picker !== null ? (_jsx(Pane, { title: pickerTitle, focused: true, height: 2 + pickerItems.length, children: pickerItems.map((item, index) => (_jsx(HarnessRow, { item: item, selected: index === pickerIndex }, String(item.harness)))) })) : editing !== null ? (_jsx("box", { border: true, borderStyle: "rounded", borderColor: COBALT, title: ` label — ${editing.harness} ${editing.id.slice(0, 8)} `, titleAlignment: "left", backgroundColor: "transparent", height: 3, flexShrink: 0, children: _jsx("input", { focused: true, value: editing.label ?? "", placeholder: "a few words for what this session is doing (empty clears)", backgroundColor: "transparent", focusedBackgroundColor: "transparent", textColor: INK, focusedTextColor: INK, placeholderColor: FAINT, cursorColor: INK, flexGrow: 1, onSubmit: (value) => {
|
|
1074
|
+
return (_jsxs("box", { flexGrow: 1, flexDirection: "column", backgroundColor: "transparent", children: [_jsxs("box", { height: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: INK, children: " mend" }), _jsxs("span", { fg: MUTED, children: [" ", projectItems.length, " project", projectItems.length === 1 ? "" : "s"] }), _jsx("span", { fg: FAINT, children: " \u00B7 " }), _jsxs("span", { fg: liveTotal > 0 ? MUTED : FAINT, children: [liveTotal, " live"] }), shareState === "off" ? null : (_jsxs(_Fragment, { children: [_jsx("span", { fg: FAINT, children: " \u00B7 " }), _jsx("span", { fg: shareState === "connected" ? MUTED : FAINT, children: shareState === "connected" ? "agent shared" : "agent share reconnecting" })] }))] }), _jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: `${ctx.config.url} ` })] }), _jsxs("box", { flexGrow: 1, flexShrink: 1, minHeight: 0, flexDirection: "row", children: [showProjectsPane ? (_jsx(Pane, { title: "projects", focused: focus === "projects", width: PROJECTS_PANE_WIDTH, children: _jsxs("scrollbox", { ref: projectScrollRef, flexGrow: 1, flexShrink: 1, minHeight: 0, style: paneScrollStyle, children: [projectItems.map((item, index) => (_jsx(ProjectRow, { item: item, selected: index === projectIndex, nameWidth: nameWidth }, item.project.id))), data !== undefined && projectItems.length === 0 ? (_jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: " none adopted yet" })) : null] }) })) : null, _jsx(Pane, { title: sessionsTitle, focused: focus === "sessions", grow: true, children: _jsxs("scrollbox", { ref: sessionScrollRef, flexGrow: 1, flexShrink: 1, minHeight: 0, style: paneScrollStyle, children: [rows.map((row, index) => row.kind === "worktree" ? (_jsx(WorktreeHeaderRow, { group: row.group, selected: index === rowIndex }, rowKeyOf(row))) : (_jsx(SessionChildRow, { item: row.item, selected: index === rowIndex }, rowKeyOf(row)))), data !== undefined && rows.length === 0 ? (_jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: " no sessions yet — n starts one" })) : null, loadFailure !== null ? (_jsx("text", { height: 1, fg: INK_2, bg: "transparent", children: ` ${loadFailure} — retrying` })) : null] }) })] }), picker !== null ? (_jsx(Pane, { title: pickerTitle, focused: true, height: 2 + pickerItems.length, children: pickerItems.map((item, index) => (_jsx(HarnessRow, { item: item, selected: index === pickerIndex }, String(item.harness)))) })) : editing !== null ? (_jsx("box", { border: true, borderStyle: "rounded", borderColor: COBALT, title: ` label — ${editing.harness} ${editing.id.slice(0, 8)} `, titleAlignment: "left", backgroundColor: "transparent", height: 3, flexShrink: 0, children: _jsx("input", { focused: true, value: editing.label ?? "", placeholder: "a few words for what this session is doing (empty clears)", backgroundColor: "transparent", focusedBackgroundColor: "transparent", textColor: INK, focusedTextColor: INK, placeholderColor: FAINT, cursorColor: INK, flexGrow: 1, onSubmit: (value) => {
|
|
1055
1075
|
if (typeof value === "string")
|
|
1056
1076
|
submitRename(editing, value);
|
|
1057
1077
|
} }) })) : showDetail ? (_jsx(Pane, { title: detailTitle, focused: false, height: 6, children: _jsx(SessionDetail, { item: selectedSession }) })) : null, adoptOffer !== null ? (_jsxs("box", { position: "absolute", zIndex: 11, left: Math.max(1, Math.floor((terminalCols - Math.min(70, terminalCols - 4)) / 2)), top: Math.max(1, Math.floor((terminalRows - 7) / 2)), width: Math.min(70, terminalCols - 4), height: 7, border: true, borderStyle: "rounded", borderColor: COBALT, title: " adopt this repository? ", titleAlignment: "left", backgroundColor: SURFACE, flexDirection: "column", children: [_jsxs("text", { height: 1, bg: SURFACE, children: [_jsx("span", { children: " " }), _jsx("span", { fg: INK, children: adoptOffer.name }), _jsx("span", { fg: FAINT, children: " · not in the store yet" })] }), _jsx("text", { height: 1, bg: SURFACE, fg: MUTED, children: ` ${adoptOffer.source}` }), _jsxs("text", { height: 1, bg: SURFACE, children: [_jsx("span", { fg: FAINT, children: " auth " }), ADOPT_AUTH_MODES.map((candidate, index) => (_jsxs("span", { children: [index > 0 ? _jsx("span", { fg: FAINT, children: " · " }) : null, _jsx("span", { fg: index === adoptOffer.modeIndex ? COBALT : FAINT, children: index === adoptOffer.modeIndex ? `▸ ${candidate.label}` : candidate.label })] }, candidate.mode)))] }), _jsx("text", { height: 1, bg: SURFACE, fg: FAINT, children: ` ${ADOPT_AUTH_MODES[adoptOffer.modeIndex]?.hint ?? ""}` }), _jsx("text", { height: 1, bg: SURFACE, fg: FAINT, children: adoptOffer.source === adoptOffer.root
|
package/dist/help.js
CHANGED
|
@@ -63,11 +63,11 @@ export const COMMANDS = [
|
|
|
63
63
|
synopsis: ["[source] [--name <name>] [--auth ambient|mend-key|bridge]"],
|
|
64
64
|
description: [
|
|
65
65
|
"Clones the repository into Mend's store. Every session then gets its own worktree of it. The default source is the current directory; any git URL works, GitHub, GitLab, self-hosted, ssh://, or a local path.",
|
|
66
|
-
"--auth says how the store fetches from the remote.
|
|
66
|
+
"--auth says how the store fetches from the remote. Default: your mode from mend keys mode. mend-key signs with your Mend key on the server (see mend keys). bridge relays this machine's ssh-agent while a mend command runs here, so hardware keys stay on your desk. ambient uses the server's own credentials.",
|
|
67
67
|
],
|
|
68
68
|
options: [
|
|
69
69
|
{ flag: "--name <name>", text: "the project's name in Mend. Default: the repository's" },
|
|
70
|
-
{ flag: "--auth <mode>", text: "
|
|
70
|
+
{ flag: "--auth <mode>", text: "mend-key, bridge, or ambient. Default: mend keys mode" },
|
|
71
71
|
],
|
|
72
72
|
examples: [
|
|
73
73
|
{ command: "mend adopt", text: "the repository you are standing in" },
|
|
@@ -471,21 +471,33 @@ export const COMMANDS = [
|
|
|
471
471
|
{
|
|
472
472
|
name: "keys init",
|
|
473
473
|
section: "project setup",
|
|
474
|
-
summary: "
|
|
474
|
+
summary: "create your Mend key (ed25519) on the server",
|
|
475
475
|
synopsis: [],
|
|
476
476
|
description: [
|
|
477
|
-
"
|
|
477
|
+
"One key per user, held on the server, never copied anywhere. Add the public key to your git account's SSH keys and every repository you can reach works, from detached sessions and the phone too. For one repository only, add it as that repository's deploy key instead.",
|
|
478
478
|
],
|
|
479
|
-
see: ["keys show", "adopt"],
|
|
479
|
+
see: ["keys show", "keys mode", "adopt"],
|
|
480
480
|
},
|
|
481
481
|
{
|
|
482
482
|
name: "keys show",
|
|
483
483
|
section: "project setup",
|
|
484
|
-
summary: "print
|
|
484
|
+
summary: "print your Mend public key",
|
|
485
485
|
synopsis: [],
|
|
486
|
-
description: [
|
|
486
|
+
description: [
|
|
487
|
+
"The key to add to your git account's SSH keys, or as a deploy key on one repository.",
|
|
488
|
+
],
|
|
487
489
|
see: ["keys init"],
|
|
488
490
|
},
|
|
491
|
+
{
|
|
492
|
+
name: "keys mode",
|
|
493
|
+
section: "project setup",
|
|
494
|
+
summary: "how your remotes are reached: mend-key or bridge",
|
|
495
|
+
synopsis: ["[mend-key|bridge]"],
|
|
496
|
+
description: [
|
|
497
|
+
"mend-key (the default) signs with your Mend key on the server and works whenever the server is up. bridge signs with this machine's ssh-agent, so a hardware key never leaves your desk, but only while a mend command is running here. New projects adopt with this mode; a project's setup page can override it. Without a value, prints the current mode.",
|
|
498
|
+
],
|
|
499
|
+
see: ["keys init", "keys share"],
|
|
500
|
+
},
|
|
489
501
|
{
|
|
490
502
|
name: "keys share",
|
|
491
503
|
section: "project setup",
|
|
@@ -493,8 +505,9 @@ export const COMMANDS = [
|
|
|
493
505
|
synopsis: [],
|
|
494
506
|
description: [
|
|
495
507
|
"Bridge mode for projects adopted with --auth bridge: the server's git operations sign with the keys in this machine's ssh-agent, so hardware keys never leave here. Runs until Ctrl-C.",
|
|
508
|
+
"When your mode is bridge (mend keys mode), every attaching mend command and the dashboard do this on their own for as long as they run, so this command is for a machine that is not running one. The server takes one signer at a time; a newer share replaces the older one.",
|
|
496
509
|
],
|
|
497
|
-
see: ["adopt"],
|
|
510
|
+
see: ["keys mode", "adopt"],
|
|
498
511
|
},
|
|
499
512
|
// ── this machine ───────────────────────────────────────────────────────
|
|
500
513
|
{
|
package/dist/main.js
CHANGED
|
@@ -5,6 +5,7 @@ import { createRequire } from "node:module";
|
|
|
5
5
|
import * as net from "node:net";
|
|
6
6
|
import * as os from "node:os";
|
|
7
7
|
import * as path from "node:path";
|
|
8
|
+
import { bridgeUrlOf, shareAgent, startAgentShare } from "./agent-share.js";
|
|
8
9
|
import { readClipboardImage } from "./clipboard.js";
|
|
9
10
|
import { doctorCommand } from "./doctor.js";
|
|
10
11
|
import { readSyncFiles, scanDotfileCandidates } from "./dotfiles.js";
|
|
@@ -195,7 +196,7 @@ const adopt = async (config, args) => {
|
|
|
195
196
|
say(`${dim(" default branch")} ${project.defaultBranch}`);
|
|
196
197
|
// Say which signer did the work — the clone already proved it answers.
|
|
197
198
|
if (project.gitAuthMode === "mend-key") {
|
|
198
|
-
say(`${dim(" git auth")} mend key ${dim("(
|
|
199
|
+
say(`${dim(" git auth")} mend key ${dim("(your Mend key signed this clone)")}`);
|
|
199
200
|
}
|
|
200
201
|
else if (project.gitAuthMode === "bridge") {
|
|
201
202
|
say(`${dim(" git auth")} bridge ${dim("(signed through the connected `mend keys share`)")}`);
|
|
@@ -1474,9 +1475,9 @@ const printGitKey = (key) => {
|
|
|
1474
1475
|
say(key.publicKey);
|
|
1475
1476
|
if (key.fingerprint !== null)
|
|
1476
1477
|
say(dim(` ${key.fingerprint}`));
|
|
1477
|
-
say(dim(" add this
|
|
1478
|
-
say(dim("
|
|
1479
|
-
say(dim("
|
|
1478
|
+
say(dim(" add this to your git account's SSH keys (GitHub: settings → SSH keys) so"));
|
|
1479
|
+
say(dim(" every repository you can reach works; for one repository only, add it"));
|
|
1480
|
+
say(dim(" as that repository's deploy key instead (grant write if sessions push)"));
|
|
1480
1481
|
};
|
|
1481
1482
|
const keysShow = async (config) => {
|
|
1482
1483
|
const key = await api(config, "GET", "/keys/git");
|
|
@@ -1492,125 +1493,73 @@ const keysInit = async (config) => {
|
|
|
1492
1493
|
printGitKey(key);
|
|
1493
1494
|
};
|
|
1494
1495
|
/**
|
|
1495
|
-
*
|
|
1496
|
-
* the
|
|
1497
|
-
* bridge-mode git ops sign with a key that never leaves this machine — a
|
|
1498
|
-
* hardware key blinks here, not on the server. Agent-protocol messages are
|
|
1499
|
-
* relayed verbatim (length-prefixed frames, base64 over JSON); the only
|
|
1500
|
-
* inspection is each message's type byte, to say what is being asked.
|
|
1496
|
+
* `mend keys share`: the ssh-agent bridge in the foreground (agent-share.ts
|
|
1497
|
+
* holds the relay). Prints every connect, drop, and signature; Ctrl-C stops.
|
|
1501
1498
|
*/
|
|
1502
1499
|
const keysShare = async (config) => {
|
|
1503
1500
|
const agentSock = process.env["SSH_AUTH_SOCK"];
|
|
1504
1501
|
if (agentSock === undefined || agentSock === "") {
|
|
1505
1502
|
return fail("SSH_AUTH_SOCK is not set — start (or plug in) your ssh-agent first");
|
|
1506
1503
|
}
|
|
1507
|
-
const url = parseMendUrl(
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
const askLocalAgent = (payload) => new Promise((resolve, reject) => {
|
|
1518
|
-
const connection = net.connect(agentSock, () => {
|
|
1519
|
-
connection.write(payload);
|
|
1520
|
-
});
|
|
1521
|
-
const timer = setTimeout(() => {
|
|
1522
|
-
connection.destroy();
|
|
1523
|
-
reject(new Error("the agent did not answer within 60s — touch missed?"));
|
|
1524
|
-
}, 60_000);
|
|
1525
|
-
let pending = Buffer.alloc(0);
|
|
1526
|
-
connection.on("data", (chunk) => {
|
|
1527
|
-
pending = Buffer.concat([pending, chunk]);
|
|
1528
|
-
if (pending.length >= 4 && pending.length >= 4 + pending.readUInt32BE(0)) {
|
|
1529
|
-
clearTimeout(timer);
|
|
1530
|
-
connection.end();
|
|
1531
|
-
resolve(pending.subarray(0, 4 + pending.readUInt32BE(0)));
|
|
1532
|
-
}
|
|
1533
|
-
});
|
|
1534
|
-
connection.on("error", (error) => {
|
|
1535
|
-
clearTimeout(timer);
|
|
1536
|
-
reject(error);
|
|
1537
|
-
});
|
|
1538
|
-
});
|
|
1539
|
-
// Strictly one request at a time, in arrival order — the agent protocol is
|
|
1540
|
-
// request/response and a hardware key cannot answer two touches at once.
|
|
1541
|
-
let chain = Promise.resolve();
|
|
1542
|
-
const SSH_AGENTC_SIGN_REQUEST = 13;
|
|
1543
|
-
const SSH_AGENTC_REQUEST_IDENTITIES = 11;
|
|
1544
|
-
let attempt = 0;
|
|
1545
|
-
for (;;) {
|
|
1546
|
-
const ws = new WebSocket(url);
|
|
1547
|
-
const closed = new Promise((resolve) => {
|
|
1548
|
-
ws.addEventListener("close", () => resolve(), { once: true });
|
|
1549
|
-
});
|
|
1550
|
-
const opened = await new Promise((resolve) => {
|
|
1551
|
-
ws.addEventListener("open", () => resolve(true), { once: true });
|
|
1552
|
-
ws.addEventListener("error", () => resolve(false), { once: true });
|
|
1553
|
-
});
|
|
1554
|
-
if (opened) {
|
|
1555
|
-
attempt = 0;
|
|
1556
|
-
say(`${green("●")} sharing this machine's ssh-agent with ${config.url}`);
|
|
1557
|
-
say(dim(` agent: ${agentSock} · signature requests print here · Ctrl-C stops sharing`));
|
|
1558
|
-
ws.addEventListener("message", (event) => {
|
|
1559
|
-
const text = typeof event.data === "string"
|
|
1560
|
-
? event.data
|
|
1561
|
-
: Buffer.from(event.data).toString("utf8");
|
|
1562
|
-
let frame;
|
|
1563
|
-
try {
|
|
1564
|
-
frame = JSON.parse(text);
|
|
1565
|
-
}
|
|
1566
|
-
catch {
|
|
1504
|
+
const url = bridgeUrlOf(parseMendUrl(config.url).toString().replace(/\/$/, ""), config.token, os.hostname());
|
|
1505
|
+
await shareAgent({
|
|
1506
|
+
url,
|
|
1507
|
+
agentSock,
|
|
1508
|
+
signal: new AbortController().signal,
|
|
1509
|
+
onEvent: (event) => {
|
|
1510
|
+
switch (event.kind) {
|
|
1511
|
+
case "connected":
|
|
1512
|
+
say(`${green("●")} sharing this machine's ssh-agent with ${config.url}`);
|
|
1513
|
+
say(dim(` agent: ${agentSock} · signature requests print here · Ctrl-C stops sharing`));
|
|
1567
1514
|
return;
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
typeof frame.id !== "number" ||
|
|
1571
|
-
typeof frame.payload !== "string") {
|
|
1515
|
+
case "disconnected":
|
|
1516
|
+
say(dim(`not connected — retrying in ${Math.round(event.retryMs / 1000)}s (Ctrl-C stops)`));
|
|
1572
1517
|
return;
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
}
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1518
|
+
case "sign-requested":
|
|
1519
|
+
say(`${amber("✎")} signature requested by mend ${dim(`(${event.context})`)}`);
|
|
1520
|
+
say(dim(" waiting — touch your key if it blinks (up to 60s)…"));
|
|
1521
|
+
return;
|
|
1522
|
+
case "identities-requested":
|
|
1523
|
+
say(dim(` identities requested (${event.context})`));
|
|
1524
|
+
return;
|
|
1525
|
+
case "signed":
|
|
1526
|
+
say(`${green("✓")} signed ${dim(`(${event.seconds.toFixed(1)}s)`)}`);
|
|
1527
|
+
return;
|
|
1528
|
+
case "not-signed":
|
|
1529
|
+
say(`${amber("✗")} not signed — ${event.message}`);
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
},
|
|
1533
|
+
});
|
|
1534
|
+
};
|
|
1535
|
+
/**
|
|
1536
|
+
* `mend keys mode [mend-key|bridge]`: how this user's remotes are reached by
|
|
1537
|
+
* default. The Mend key lives on the server and works whenever the server
|
|
1538
|
+
* is up — detached sessions, the phone, the hot pool. Bridge signs with this
|
|
1539
|
+
* machine's ssh-agent and only works while a mend command is running here.
|
|
1540
|
+
*/
|
|
1541
|
+
const keysMode = async (config, args) => {
|
|
1542
|
+
const [value] = args;
|
|
1543
|
+
if (value !== undefined && value !== "mend-key" && value !== "bridge") {
|
|
1544
|
+
return fail(`keys mode takes mend-key or bridge, not "${value}"`);
|
|
1545
|
+
}
|
|
1546
|
+
const access = value === undefined
|
|
1547
|
+
? await api(config, "GET", "/me/git-access")
|
|
1548
|
+
: await api(config, "PUT", "/me/git-access", { mode: value });
|
|
1549
|
+
if (value !== undefined)
|
|
1550
|
+
say(`${green("✓")} git access · ${value}`);
|
|
1551
|
+
if (access.mode === "mend-key") {
|
|
1552
|
+
say(`mend-key ${dim("— your Mend key on the server signs; works whenever the server is up")}`);
|
|
1553
|
+
if (access.key.exists)
|
|
1554
|
+
printGitKey(access.key);
|
|
1555
|
+
else
|
|
1556
|
+
say(dim(" no key yet — mend keys init creates it"));
|
|
1557
|
+
return;
|
|
1613
1558
|
}
|
|
1559
|
+
say(`bridge ${dim("— this machine's ssh-agent signs; only while a mend command runs here")}`);
|
|
1560
|
+
say(access.bridge.connected
|
|
1561
|
+
? `${green("●")} signer connected · ${access.bridge.clientName ?? "unknown machine"}`
|
|
1562
|
+
: dim(" no signer connected — mend shares the agent whenever it runs (or: mend keys share)"));
|
|
1614
1563
|
};
|
|
1615
1564
|
const keysCommand = async (config, args) => {
|
|
1616
1565
|
const [verb] = args;
|
|
@@ -1619,6 +1568,8 @@ const keysCommand = async (config, args) => {
|
|
|
1619
1568
|
return keysInit(config);
|
|
1620
1569
|
case "share":
|
|
1621
1570
|
return keysShare(config);
|
|
1571
|
+
case "mode":
|
|
1572
|
+
return keysMode(config, args.slice(1));
|
|
1622
1573
|
case "show":
|
|
1623
1574
|
case undefined:
|
|
1624
1575
|
return keysShow(config);
|
|
@@ -2565,15 +2516,58 @@ const dashboard = async (config) => {
|
|
|
2565
2516
|
process.exit(rerun.status ?? 0);
|
|
2566
2517
|
}
|
|
2567
2518
|
const { runDashboard } = await import("./dashboard.js");
|
|
2568
|
-
await
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2519
|
+
const agentShare = await startShareIfBridge(config);
|
|
2520
|
+
try {
|
|
2521
|
+
await runDashboard({
|
|
2522
|
+
config,
|
|
2523
|
+
cwd: process.cwd(),
|
|
2524
|
+
cwdBranch: gitCurrentBranch(process.cwd()),
|
|
2525
|
+
api: (method, route, body) => request(config, method, route, body),
|
|
2526
|
+
attachTty: (sessionId, harness, processId) => attachTty(config, sessionId, harness, 0n, processId),
|
|
2527
|
+
agentShare,
|
|
2528
|
+
});
|
|
2529
|
+
}
|
|
2530
|
+
finally {
|
|
2531
|
+
agentShare?.stop();
|
|
2532
|
+
}
|
|
2575
2533
|
};
|
|
2576
2534
|
// ─── entry ──────────────────────────────────────────────────────────────────
|
|
2535
|
+
/**
|
|
2536
|
+
* Share this machine's ssh-agent for as long as the calling command runs —
|
|
2537
|
+
* only when the user's git access is bridge (mend keys mode). Their remotes
|
|
2538
|
+
* then sign here, and a base fetch before a worktree is created can't be
|
|
2539
|
+
* skipped for want of a signer. Any other mode, no agent, or an unreachable
|
|
2540
|
+
* server: nothing is shared. A newer share replaces an older one on the
|
|
2541
|
+
* server, so overlapping mend commands hand over rather than fight.
|
|
2542
|
+
*/
|
|
2543
|
+
const startShareIfBridge = async (config) => {
|
|
2544
|
+
const agentSock = process.env["SSH_AUTH_SOCK"];
|
|
2545
|
+
if (agentSock === undefined || agentSock === "")
|
|
2546
|
+
return null;
|
|
2547
|
+
let access;
|
|
2548
|
+
try {
|
|
2549
|
+
access = await request(config, "GET", "/me/git-access");
|
|
2550
|
+
}
|
|
2551
|
+
catch {
|
|
2552
|
+
return null;
|
|
2553
|
+
}
|
|
2554
|
+
if (access.mode !== "bridge")
|
|
2555
|
+
return null;
|
|
2556
|
+
return startAgentShare({
|
|
2557
|
+
url: bridgeUrlOf(parseMendUrl(config.url).toString().replace(/\/$/, ""), config.token, os.hostname()),
|
|
2558
|
+
agentSock,
|
|
2559
|
+
});
|
|
2560
|
+
};
|
|
2561
|
+
/** Run one command under the share (see startShareIfBridge); stop it on the way out. */
|
|
2562
|
+
const withAgentShare = async (config, work) => {
|
|
2563
|
+
const share = await startShareIfBridge(config);
|
|
2564
|
+
try {
|
|
2565
|
+
return await work();
|
|
2566
|
+
}
|
|
2567
|
+
finally {
|
|
2568
|
+
share?.stop();
|
|
2569
|
+
}
|
|
2570
|
+
};
|
|
2577
2571
|
/** `mend help [command...]`: the index, a group, or one page. */
|
|
2578
2572
|
const helpCommand = (words) => {
|
|
2579
2573
|
if (words.length === 0) {
|
|
@@ -2630,13 +2624,13 @@ const main = async () => {
|
|
|
2630
2624
|
case "claude":
|
|
2631
2625
|
case "opencode":
|
|
2632
2626
|
case "run":
|
|
2633
|
-
return launch(config, command, rest);
|
|
2627
|
+
return withAgentShare(config, () => launch(config, command, rest));
|
|
2634
2628
|
case "attach":
|
|
2635
|
-
return attach(config, rest);
|
|
2629
|
+
return withAgentShare(config, () => attach(config, rest));
|
|
2636
2630
|
case "stop":
|
|
2637
2631
|
return stopCommand(config, rest);
|
|
2638
2632
|
case "shell":
|
|
2639
|
-
return shellCommand(config, rest);
|
|
2633
|
+
return withAgentShare(config, () => shellCommand(config, rest));
|
|
2640
2634
|
case "service":
|
|
2641
2635
|
return serviceCommand(config, rest);
|
|
2642
2636
|
case "login":
|
|
@@ -2671,9 +2665,9 @@ const main = async () => {
|
|
|
2671
2665
|
case "continue":
|
|
2672
2666
|
return continueSession(config, rest);
|
|
2673
2667
|
case "resume":
|
|
2674
|
-
return resumeCommand(config, rest);
|
|
2668
|
+
return withAgentShare(config, () => resumeCommand(config, rest));
|
|
2675
2669
|
case "rejoin":
|
|
2676
|
-
return rejoinCommand(config, rest);
|
|
2670
|
+
return withAgentShare(config, () => rejoinCommand(config, rest));
|
|
2677
2671
|
case "projects":
|
|
2678
2672
|
return projectsCommand(config);
|
|
2679
2673
|
case "refresh":
|
package/dist/shared.js
CHANGED
|
@@ -161,17 +161,7 @@ export const parseLaunchArgs = (args) => {
|
|
|
161
161
|
export const isDetachChunk = (data) => {
|
|
162
162
|
if (data.includes(0x1d))
|
|
163
163
|
return true;
|
|
164
|
-
|
|
165
|
-
// scan rather than a regex: an escape byte inside a pattern trips lint, and
|
|
166
|
-
// anchoring on the real ESC[ prefix keeps pasted text from ever matching.
|
|
167
|
-
const text = data.toString("latin1");
|
|
168
|
-
const prefix = "\u001b[93;5";
|
|
169
|
-
for (let start = text.indexOf(prefix); start !== -1; start = text.indexOf(prefix, start + 1)) {
|
|
170
|
-
const rest = text.slice(start + prefix.length);
|
|
171
|
-
if (rest.startsWith("u") || rest.startsWith(":1u") || rest.startsWith(":2u"))
|
|
172
|
-
return true;
|
|
173
|
-
}
|
|
174
|
-
return false;
|
|
164
|
+
return csiUKeysOf(data).some((key) => key.code === 93 && isCtrlChord(key) && isPressOrRepeat(key));
|
|
175
165
|
};
|
|
176
166
|
/**
|
|
177
167
|
* What to call a session's worktree in banners and rows: a NAMED worktree is
|
|
@@ -293,16 +283,43 @@ export const normalizeProjectName = (raw) => {
|
|
|
293
283
|
.replace(/-+$/, "");
|
|
294
284
|
return slug === "" ? "project" : slug.slice(0, 64);
|
|
295
285
|
};
|
|
286
|
+
// Built from a string: an escape byte inside a regex literal trips lint.
|
|
287
|
+
const CSI_U = new RegExp(`${String.fromCharCode(0x1b)}\\[(\\d+)(?::\\d*)*(?:;(\\d+)(?::(\\d+))?)?(?:;[\\d:]+)?u`, "g");
|
|
288
|
+
const LOCK_MODIFIERS = 64 | 128;
|
|
289
|
+
/** Every CSI-u key report inside a chunk, in order. */
|
|
290
|
+
export const csiUKeysOf = (data) => {
|
|
291
|
+
// latin1 maps bytes 1:1, so the scan sees exactly the wire bytes.
|
|
292
|
+
const text = data.toString("latin1");
|
|
293
|
+
const keys = [];
|
|
294
|
+
for (const match of text.matchAll(CSI_U)) {
|
|
295
|
+
const code = Number(match[1]);
|
|
296
|
+
const mods = (match[2] === undefined ? 1 : Number(match[2])) - 1;
|
|
297
|
+
const event = match[3] === undefined ? 1 : Number(match[3]);
|
|
298
|
+
keys.push({ code, mods: mods & ~LOCK_MODIFIERS, event });
|
|
299
|
+
}
|
|
300
|
+
return keys;
|
|
301
|
+
};
|
|
302
|
+
const CTRL = 4;
|
|
303
|
+
const SHIFT = 1;
|
|
304
|
+
/** Ctrl held, optionally shift, nothing else (alt/super/hyper/meta make it another chord). */
|
|
305
|
+
const isCtrlChord = (key) => (key.mods & ~SHIFT) === CTRL;
|
|
306
|
+
const isPressOrRepeat = (key) => key.event === 1 || key.event === 2;
|
|
296
307
|
/**
|
|
297
|
-
* Whether one stdin chunk is
|
|
298
|
-
*
|
|
299
|
-
*
|
|
308
|
+
* Whether one stdin chunk is Ctrl+V — the raw 0x16 byte on its own, or one
|
|
309
|
+
* CSI-u report of `v` (118) or `V` (86) with ctrl, on press or repeat. Exact
|
|
310
|
+
* for the raw byte: a paste that happens to carry 0x16 is not a request.
|
|
300
311
|
*/
|
|
301
312
|
export const isPasteChunk = (data) => {
|
|
302
313
|
if (data.length === 1 && data[0] === 0x16)
|
|
303
314
|
return true;
|
|
304
|
-
const
|
|
305
|
-
|
|
315
|
+
const keys = csiUKeysOf(data);
|
|
316
|
+
const key = keys[0];
|
|
317
|
+
return (keys.length === 1 &&
|
|
318
|
+
key !== undefined &&
|
|
319
|
+
(key.code === 118 || key.code === 86) &&
|
|
320
|
+
isCtrlChord(key) &&
|
|
321
|
+
isPressOrRepeat(key) &&
|
|
322
|
+
data.toString("latin1").startsWith("\u001b["));
|
|
306
323
|
};
|
|
307
324
|
/**
|
|
308
325
|
* Follow the remote app's bracketed-paste mode through the bytes it writes:
|
package/man/mend-accounts.1
CHANGED
package/man/mend-adopt.1
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
.TH MEND-ADOPT 1 "" "mend 0.
|
|
1
|
+
.TH MEND-ADOPT 1 "" "mend 0.21.0" "mend manual"
|
|
2
2
|
.SH NAME
|
|
3
3
|
mend\-adopt \- adopt a repository into the store
|
|
4
4
|
.SH SYNOPSIS
|
|
@@ -8,14 +8,14 @@ mend\-adopt \- adopt a repository into the store
|
|
|
8
8
|
.PP
|
|
9
9
|
Clones the repository into Mend's store. Every session then gets its own worktree of it. The default source is the current directory; any git URL works, GitHub, GitLab, self\-hosted, ssh://, or a local path.
|
|
10
10
|
.PP
|
|
11
|
-
\-\-auth says how the store fetches from the remote.
|
|
11
|
+
\-\-auth says how the store fetches from the remote. Default: your mode from mend keys mode. mend\-key signs with your Mend key on the server (see mend keys). bridge relays this machine's ssh\-agent while a mend command runs here, so hardware keys stay on your desk. ambient uses the server's own credentials.
|
|
12
12
|
.SH OPTIONS
|
|
13
13
|
.TP
|
|
14
14
|
.B \-\-name <name>
|
|
15
15
|
the project's name in Mend. Default: the repository's
|
|
16
16
|
.TP
|
|
17
17
|
.B \-\-auth <mode>
|
|
18
|
-
|
|
18
|
+
mend\-key, bridge, or ambient. Default: mend keys mode
|
|
19
19
|
.SH EXAMPLES
|
|
20
20
|
.PP
|
|
21
21
|
.B mend adopt
|
package/man/mend-attach.1
CHANGED
package/man/mend-codex.1
CHANGED
package/man/mend-completions.1
CHANGED
package/man/mend-connect.1
CHANGED
package/man/mend-continue.1
CHANGED
package/man/mend-doctor.1
CHANGED
package/man/mend-dotfiles-sync.1
CHANGED
package/man/mend-dotfiles.1
CHANGED
package/man/mend-env-cluster.1
CHANGED
package/man/mend-env-load.1
CHANGED
package/man/mend-env-show.1
CHANGED
package/man/mend-help.1
CHANGED
package/man/mend-keys-init.1
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
.TH MEND-KEYS-INIT 1 "" "mend 0.
|
|
1
|
+
.TH MEND-KEYS-INIT 1 "" "mend 0.21.0" "mend manual"
|
|
2
2
|
.SH NAME
|
|
3
|
-
mend\-keys\-init \-
|
|
3
|
+
mend\-keys\-init \- create your Mend key (ed25519) on the server
|
|
4
4
|
.SH SYNOPSIS
|
|
5
5
|
.B mend keys init
|
|
6
6
|
.br
|
|
7
7
|
.SH DESCRIPTION
|
|
8
8
|
.PP
|
|
9
|
-
|
|
9
|
+
One key per user, held on the server, never copied anywhere. Add the public key to your git account's SSH keys and every repository you can reach works, from detached sessions and the phone too. For one repository only, add it as that repository's deploy key instead.
|
|
10
10
|
.SH SEE ALSO
|
|
11
|
-
mend(1), mend\-keys\-show(1), mend\-adopt(1)
|
|
11
|
+
mend(1), mend\-keys\-show(1), mend\-keys\-mode(1), mend\-adopt(1)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
.TH MEND-KEYS-MODE 1 "" "mend 0.21.0" "mend manual"
|
|
2
|
+
.SH NAME
|
|
3
|
+
mend\-keys\-mode \- how your remotes are reached: mend\-key or bridge
|
|
4
|
+
.SH SYNOPSIS
|
|
5
|
+
.B mend keys mode [mend\-key|bridge]
|
|
6
|
+
.br
|
|
7
|
+
.SH DESCRIPTION
|
|
8
|
+
.PP
|
|
9
|
+
mend\-key (the default) signs with your Mend key on the server and works whenever the server is up. bridge signs with this machine's ssh\-agent, so a hardware key never leaves your desk, but only while a mend command is running here. New projects adopt with this mode; a project's setup page can override it. Without a value, prints the current mode.
|
|
10
|
+
.SH SEE ALSO
|
|
11
|
+
mend(1), mend\-keys\-init(1), mend\-keys\-share(1)
|
package/man/mend-keys-share.1
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
.TH MEND-KEYS-SHARE 1 "" "mend 0.
|
|
1
|
+
.TH MEND-KEYS-SHARE 1 "" "mend 0.21.0" "mend manual"
|
|
2
2
|
.SH NAME
|
|
3
3
|
mend\-keys\-share \- relay this machine's ssh\-agent to the server
|
|
4
4
|
.SH SYNOPSIS
|
|
@@ -7,5 +7,7 @@ mend\-keys\-share \- relay this machine's ssh\-agent to the server
|
|
|
7
7
|
.SH DESCRIPTION
|
|
8
8
|
.PP
|
|
9
9
|
Bridge mode for projects adopted with \-\-auth bridge: the server's git operations sign with the keys in this machine's ssh\-agent, so hardware keys never leave here. Runs until Ctrl\-C.
|
|
10
|
+
.PP
|
|
11
|
+
When your mode is bridge (mend keys mode), every attaching mend command and the dashboard do this on their own for as long as they run, so this command is for a machine that is not running one. The server takes one signer at a time; a newer share replaces the older one.
|
|
10
12
|
.SH SEE ALSO
|
|
11
|
-
mend(1), mend\-adopt(1)
|
|
13
|
+
mend(1), mend\-keys\-mode(1), mend\-adopt(1)
|
package/man/mend-keys-show.1
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
.TH MEND-KEYS-SHOW 1 "" "mend 0.
|
|
1
|
+
.TH MEND-KEYS-SHOW 1 "" "mend 0.21.0" "mend manual"
|
|
2
2
|
.SH NAME
|
|
3
|
-
mend\-keys\-show \- print
|
|
3
|
+
mend\-keys\-show \- print your Mend public key
|
|
4
4
|
.SH SYNOPSIS
|
|
5
5
|
.B mend keys show
|
|
6
6
|
.br
|
|
7
7
|
.SH DESCRIPTION
|
|
8
8
|
.PP
|
|
9
|
-
The key to add as a deploy key on
|
|
9
|
+
The key to add to your git account's SSH keys, or as a deploy key on one repository.
|
|
10
10
|
.SH SEE ALSO
|
|
11
11
|
mend(1), mend\-keys\-init(1)
|
package/man/mend-login.1
CHANGED
package/man/mend-logout.1
CHANGED
package/man/mend-man.1
CHANGED
package/man/mend-pair.1
CHANGED
package/man/mend-projects.1
CHANGED
package/man/mend-refresh.1
CHANGED
package/man/mend-rejoin.1
CHANGED
package/man/mend-resume.1
CHANGED
package/man/mend-run.1
CHANGED
package/man/mend-service-add.1
CHANGED
package/man/mend-service-init.1
CHANGED
package/man/mend-service-list.1
CHANGED
package/man/mend-service-logs.1
CHANGED
package/man/mend-service-run.1
CHANGED
package/man/mend-service-stop.1
CHANGED
package/man/mend-sessions.1
CHANGED
package/man/mend-shell.1
CHANGED
package/man/mend-skills-push.1
CHANGED
package/man/mend-skills.1
CHANGED
package/man/mend-ssh-setup.1
CHANGED
package/man/mend-ssh.1
CHANGED
package/man/mend-stop.1
CHANGED
package/man/mend-ui.1
CHANGED
package/man/mend-version.1
CHANGED
package/man/mend-worktrees.1
CHANGED
package/man/mend.1
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
.TH MEND 1 "" "mend 0.
|
|
1
|
+
.TH MEND 1 "" "mend 0.21.0" "mend manual"
|
|
2
2
|
.SH NAME
|
|
3
3
|
mend \- the agent workbench
|
|
4
4
|
.SH SYNOPSIS
|
|
@@ -112,10 +112,13 @@ your dotfiles on the server: repo and synced files
|
|
|
112
112
|
capture config files from this machine into your store
|
|
113
113
|
.TP
|
|
114
114
|
.B mend keys init
|
|
115
|
-
|
|
115
|
+
create your Mend key (ed25519) on the server
|
|
116
116
|
.TP
|
|
117
117
|
.B mend keys show
|
|
118
|
-
print
|
|
118
|
+
print your Mend public key
|
|
119
|
+
.TP
|
|
120
|
+
.B mend keys mode
|
|
121
|
+
how your remotes are reached: mend\-key or bridge
|
|
119
122
|
.TP
|
|
120
123
|
.B mend keys share
|
|
121
124
|
relay this machine's ssh\-agent to the server
|
|
@@ -162,4 +165,4 @@ url, token, and device id from mend login
|
|
|
162
165
|
.B ~/.config/mend/keys/
|
|
163
166
|
the deploy key from mend keys init
|
|
164
167
|
.SH SEE ALSO
|
|
165
|
-
mend\-login(1), mend\-connect(1), mend\-adopt(1), mend\-codex(1), mend\-pair(1), mend\-doctor(1), mend\-ui(1), mend\-attach(1), mend\-stop(1), mend\-shell(1), mend\-run(1), mend\-continue(1), mend\-resume(1), mend\-rejoin(1), mend\-sessions(1), mend\-worktrees(1), mend\-projects(1), mend\-refresh(1), mend\-service\-run(1), mend\-service\-add(1), mend\-service\-connect(1), mend\-service\-list(1), mend\-service\-logs(1), mend\-service\-restart(1), mend\-service\-stop(1), mend\-service\-init(1), mend\-env\-show(1), mend\-env\-load(1), mend\-env\-cluster(1), mend\-skills(1), mend\-skills\-push(1), mend\-dotfiles(1), mend\-dotfiles\-sync(1), mend\-keys\-init(1), mend\-keys\-show(1), mend\-keys\-share(1), mend\-ssh(1), mend\-ssh\-setup(1), mend\-accounts(1), mend\-logout(1), mend\-completions(1), mend\-version(1), mend\-help(1), mend\-man(1)
|
|
168
|
+
mend\-login(1), mend\-connect(1), mend\-adopt(1), mend\-codex(1), mend\-pair(1), mend\-doctor(1), mend\-ui(1), mend\-attach(1), mend\-stop(1), mend\-shell(1), mend\-run(1), mend\-continue(1), mend\-resume(1), mend\-rejoin(1), mend\-sessions(1), mend\-worktrees(1), mend\-projects(1), mend\-refresh(1), mend\-service\-run(1), mend\-service\-add(1), mend\-service\-connect(1), mend\-service\-list(1), mend\-service\-logs(1), mend\-service\-restart(1), mend\-service\-stop(1), mend\-service\-init(1), mend\-env\-show(1), mend\-env\-load(1), mend\-env\-cluster(1), mend\-skills(1), mend\-skills\-push(1), mend\-dotfiles(1), mend\-dotfiles\-sync(1), mend\-keys\-init(1), mend\-keys\-show(1), mend\-keys\-mode(1), mend\-keys\-share(1), mend\-ssh(1), mend\-ssh\-setup(1), mend\-accounts(1), mend\-logout(1), mend\-completions(1), mend\-version(1), mend\-help(1), mend\-man(1)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sealant/mend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "The mend CLI: adopt a repository into Mend's store, launch your coding agent in a recorded session worktree, detach and reattach from any terminal",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|