@residue/cli 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/adapters/pi/extension.ts.txt +137 -0
- package/dist/index.js +4443 -0
- package/dist/residue +0 -0
- package/package.json +24 -0
- package/src/commands/capture.ts +49 -0
- package/src/commands/hook.ts +222 -0
- package/src/commands/init.ts +146 -0
- package/src/commands/login.ts +26 -0
- package/src/commands/push.ts +3 -0
- package/src/commands/session-end.ts +38 -0
- package/src/commands/session-start.ts +35 -0
- package/src/commands/setup.ts +148 -0
- package/src/commands/sync.ts +354 -0
- package/src/index.ts +99 -0
- package/src/lib/config.ts +61 -0
- package/src/lib/git.ts +95 -0
- package/src/lib/pending.ts +190 -0
- package/src/types/text-import.d.ts +4 -0
- package/src/utils/errors.ts +75 -0
- package/src/utils/logger.ts +51 -0
- package/test/commands/capture.test.ts +206 -0
- package/test/commands/hook.test.ts +224 -0
- package/test/commands/sync.test.ts +540 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { err, ok, okAsync, ResultAsync, safeTry } from "neverthrow";
|
|
2
|
+
import { readConfig } from "@/lib/config";
|
|
3
|
+
import { getCommitMeta, getRemoteUrl, parseRemote } from "@/lib/git";
|
|
4
|
+
import type { CommitRef, PendingSession } from "@/lib/pending";
|
|
5
|
+
import {
|
|
6
|
+
getPendingPath,
|
|
7
|
+
getProjectRoot,
|
|
8
|
+
readPending,
|
|
9
|
+
writePending,
|
|
10
|
+
} from "@/lib/pending";
|
|
11
|
+
import { CliError, toCliError } from "@/utils/errors";
|
|
12
|
+
import { createLogger } from "@/utils/logger";
|
|
13
|
+
|
|
14
|
+
const log = createLogger("sync");
|
|
15
|
+
|
|
16
|
+
import { stat } from "fs/promises";
|
|
17
|
+
|
|
18
|
+
const STALE_THRESHOLD_MS = 30 * 60 * 1000; // 30 minutes
|
|
19
|
+
|
|
20
|
+
type CommitPayload = {
|
|
21
|
+
sha: string;
|
|
22
|
+
org: string;
|
|
23
|
+
repo: string;
|
|
24
|
+
message: string;
|
|
25
|
+
author: string;
|
|
26
|
+
committed_at: number;
|
|
27
|
+
branch: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type UploadUrlResponse = {
|
|
31
|
+
url: string;
|
|
32
|
+
r2_key: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function requestUploadUrl(opts: {
|
|
36
|
+
workerUrl: string;
|
|
37
|
+
token: string;
|
|
38
|
+
sessionId: string;
|
|
39
|
+
}): ResultAsync<UploadUrlResponse, CliError> {
|
|
40
|
+
return ResultAsync.fromPromise(
|
|
41
|
+
fetch(`${opts.workerUrl}/api/sessions/upload-url`, {
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: {
|
|
44
|
+
"Content-Type": "application/json",
|
|
45
|
+
Authorization: `Bearer ${opts.token}`,
|
|
46
|
+
},
|
|
47
|
+
body: JSON.stringify({ session_id: opts.sessionId }),
|
|
48
|
+
}).then(async (response) => {
|
|
49
|
+
if (!response.ok) {
|
|
50
|
+
throw new Error(`HTTP ${response.status}`);
|
|
51
|
+
}
|
|
52
|
+
return response.json() as Promise<UploadUrlResponse>;
|
|
53
|
+
}),
|
|
54
|
+
toCliError({
|
|
55
|
+
message: "Failed to request upload URL",
|
|
56
|
+
code: "NETWORK_ERROR",
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function uploadToPresignedUrl(opts: {
|
|
62
|
+
url: string;
|
|
63
|
+
data: string;
|
|
64
|
+
}): ResultAsync<void, CliError> {
|
|
65
|
+
return ResultAsync.fromPromise(
|
|
66
|
+
fetch(opts.url, {
|
|
67
|
+
method: "PUT",
|
|
68
|
+
headers: { "Content-Type": "application/json" },
|
|
69
|
+
body: opts.data,
|
|
70
|
+
}).then((response) => {
|
|
71
|
+
if (!response.ok) {
|
|
72
|
+
throw new Error(`R2 upload failed: HTTP ${response.status}`);
|
|
73
|
+
}
|
|
74
|
+
}),
|
|
75
|
+
toCliError({ message: "Direct R2 upload failed", code: "NETWORK_ERROR" }),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function postSessionMetadata(opts: {
|
|
80
|
+
workerUrl: string;
|
|
81
|
+
token: string;
|
|
82
|
+
session: {
|
|
83
|
+
id: string;
|
|
84
|
+
agent: string;
|
|
85
|
+
agent_version: string;
|
|
86
|
+
status: string;
|
|
87
|
+
};
|
|
88
|
+
commits: CommitPayload[];
|
|
89
|
+
}): ResultAsync<void, CliError> {
|
|
90
|
+
return ResultAsync.fromPromise(
|
|
91
|
+
fetch(`${opts.workerUrl}/api/sessions`, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: {
|
|
94
|
+
"Content-Type": "application/json",
|
|
95
|
+
Authorization: `Bearer ${opts.token}`,
|
|
96
|
+
},
|
|
97
|
+
body: JSON.stringify({
|
|
98
|
+
session: opts.session,
|
|
99
|
+
commits: opts.commits,
|
|
100
|
+
}),
|
|
101
|
+
}).then((response) => {
|
|
102
|
+
if (!response.ok) {
|
|
103
|
+
throw new Error(`HTTP ${response.status}`);
|
|
104
|
+
}
|
|
105
|
+
}),
|
|
106
|
+
toCliError({ message: "Metadata upload failed", code: "NETWORK_ERROR" }),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function readSessionData(
|
|
111
|
+
dataPath: string,
|
|
112
|
+
): ResultAsync<string | null, CliError> {
|
|
113
|
+
return ResultAsync.fromPromise(
|
|
114
|
+
(async () => {
|
|
115
|
+
const file = Bun.file(dataPath);
|
|
116
|
+
const isExists = await file.exists();
|
|
117
|
+
if (!isExists) return null;
|
|
118
|
+
return file.text();
|
|
119
|
+
})(),
|
|
120
|
+
toCliError({ message: "Failed to read session data", code: "IO_ERROR" }),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function buildCommitMeta(opts: {
|
|
125
|
+
commitRefs: CommitRef[];
|
|
126
|
+
org: string;
|
|
127
|
+
repo: string;
|
|
128
|
+
}): ResultAsync<CommitPayload[], CliError> {
|
|
129
|
+
return ResultAsync.fromSafePromise(
|
|
130
|
+
(async () => {
|
|
131
|
+
const commits: CommitPayload[] = [];
|
|
132
|
+
for (const ref of opts.commitRefs) {
|
|
133
|
+
const metaResult = await getCommitMeta(ref.sha);
|
|
134
|
+
if (metaResult.isErr()) {
|
|
135
|
+
log.warn(metaResult.error);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
commits.push({
|
|
139
|
+
sha: ref.sha,
|
|
140
|
+
org: opts.org,
|
|
141
|
+
repo: opts.repo,
|
|
142
|
+
message: metaResult.value.message,
|
|
143
|
+
author: metaResult.value.author,
|
|
144
|
+
committed_at: metaResult.value.committed_at,
|
|
145
|
+
branch: ref.branch,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return commits;
|
|
149
|
+
})(),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function getFileMtimeMs(path: string): ResultAsync<number | null, CliError> {
|
|
154
|
+
return ResultAsync.fromPromise(
|
|
155
|
+
stat(path).then((s) => s.mtimeMs),
|
|
156
|
+
toCliError({ message: "Failed to stat file", code: "IO_ERROR" }),
|
|
157
|
+
).orElse(() => okAsync(null));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Mark open sessions as ended if their data file hasn't been modified
|
|
162
|
+
* in the last 30 minutes. This handles dangling sessions from crashed
|
|
163
|
+
* or closed agent processes that never called session-end.
|
|
164
|
+
*/
|
|
165
|
+
function closeStaleOpenSessions(opts: {
|
|
166
|
+
sessions: PendingSession[];
|
|
167
|
+
}): ResultAsync<PendingSession[], CliError> {
|
|
168
|
+
const now = Date.now();
|
|
169
|
+
const openSessions = opts.sessions.filter((s) => s.status === "open");
|
|
170
|
+
|
|
171
|
+
if (openSessions.length === 0) {
|
|
172
|
+
return okAsync(opts.sessions);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const checks = openSessions.map((session) =>
|
|
176
|
+
getFileMtimeMs(session.data_path).map((mtimeMs) => {
|
|
177
|
+
if (mtimeMs === null) {
|
|
178
|
+
session.status = "ended";
|
|
179
|
+
log.debug(
|
|
180
|
+
"auto-closed session %s (data file not accessible)",
|
|
181
|
+
session.id,
|
|
182
|
+
);
|
|
183
|
+
} else {
|
|
184
|
+
const msSinceModified = now - mtimeMs;
|
|
185
|
+
if (msSinceModified > STALE_THRESHOLD_MS) {
|
|
186
|
+
session.status = "ended";
|
|
187
|
+
log.debug(
|
|
188
|
+
"auto-closed stale session %s (data file unchanged for %dm)",
|
|
189
|
+
session.id,
|
|
190
|
+
Math.round(msSinceModified / 60_000),
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}),
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
return ResultAsync.combine(checks).map(() => opts.sessions);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function syncSessions(opts: {
|
|
201
|
+
sessions: PendingSession[];
|
|
202
|
+
workerUrl: string;
|
|
203
|
+
token: string;
|
|
204
|
+
org: string;
|
|
205
|
+
repo: string;
|
|
206
|
+
}): ResultAsync<PendingSession[], CliError> {
|
|
207
|
+
return ResultAsync.fromSafePromise(
|
|
208
|
+
(async () => {
|
|
209
|
+
const remaining: PendingSession[] = [];
|
|
210
|
+
|
|
211
|
+
for (const session of opts.sessions) {
|
|
212
|
+
if (session.commits.length === 0) {
|
|
213
|
+
remaining.push(session);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const dataResult = await readSessionData(session.data_path);
|
|
218
|
+
if (dataResult.isErr()) {
|
|
219
|
+
log.warn(dataResult.error);
|
|
220
|
+
remaining.push(session);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const data = dataResult.value;
|
|
225
|
+
if (data === null) {
|
|
226
|
+
log.warn(
|
|
227
|
+
`dropping session ${session.id}: data file missing at ${session.data_path}`,
|
|
228
|
+
);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const commitsResult = await buildCommitMeta({
|
|
233
|
+
commitRefs: session.commits,
|
|
234
|
+
org: opts.org,
|
|
235
|
+
repo: opts.repo,
|
|
236
|
+
});
|
|
237
|
+
if (commitsResult.isErr()) {
|
|
238
|
+
log.warn(commitsResult.error);
|
|
239
|
+
remaining.push(session);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Step 1: Get a presigned URL from the worker
|
|
244
|
+
const uploadUrlResult = await requestUploadUrl({
|
|
245
|
+
workerUrl: opts.workerUrl,
|
|
246
|
+
token: opts.token,
|
|
247
|
+
sessionId: session.id,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
if (uploadUrlResult.isErr()) {
|
|
251
|
+
log.warn(
|
|
252
|
+
`failed to get upload URL for session ${session.id}: ${uploadUrlResult.error.message}`,
|
|
253
|
+
);
|
|
254
|
+
remaining.push(session);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Step 2: Upload session data directly to R2
|
|
259
|
+
const directUploadResult = await uploadToPresignedUrl({
|
|
260
|
+
url: uploadUrlResult.value.url,
|
|
261
|
+
data,
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
if (directUploadResult.isErr()) {
|
|
265
|
+
log.warn(
|
|
266
|
+
`R2 upload failed for session ${session.id}: ${directUploadResult.error.message}`,
|
|
267
|
+
);
|
|
268
|
+
remaining.push(session);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
log.debug("uploaded session %s data directly to R2", session.id);
|
|
273
|
+
|
|
274
|
+
// Step 3: POST metadata only (no inline data)
|
|
275
|
+
const metadataResult = await postSessionMetadata({
|
|
276
|
+
workerUrl: opts.workerUrl,
|
|
277
|
+
token: opts.token,
|
|
278
|
+
session: {
|
|
279
|
+
id: session.id,
|
|
280
|
+
agent: session.agent,
|
|
281
|
+
agent_version: session.agent_version,
|
|
282
|
+
status: session.status,
|
|
283
|
+
},
|
|
284
|
+
commits: commitsResult.value,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
if (metadataResult.isErr()) {
|
|
288
|
+
log.warn(
|
|
289
|
+
`metadata upload failed for session ${session.id}: ${metadataResult.error.message}`,
|
|
290
|
+
);
|
|
291
|
+
remaining.push(session);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (session.status === "open") {
|
|
296
|
+
remaining.push(session);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
log.debug("synced session %s", session.id);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return remaining;
|
|
303
|
+
})(),
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function resolveRemote(
|
|
308
|
+
remoteUrl?: string,
|
|
309
|
+
): ResultAsync<{ org: string; repo: string }, CliError> {
|
|
310
|
+
if (remoteUrl && remoteUrl.length > 0) {
|
|
311
|
+
const result = parseRemote(remoteUrl);
|
|
312
|
+
if (result.isOk()) {
|
|
313
|
+
return okAsync(result.value);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return getRemoteUrl().andThen(parseRemote);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function sync(opts?: {
|
|
320
|
+
remoteUrl?: string;
|
|
321
|
+
}): ResultAsync<void, CliError> {
|
|
322
|
+
return safeTry(async function* () {
|
|
323
|
+
const config = yield* readConfig();
|
|
324
|
+
if (!config) {
|
|
325
|
+
return err(
|
|
326
|
+
new CliError({
|
|
327
|
+
message: "Not configured. Run 'residue login' first.",
|
|
328
|
+
code: "CONFIG_MISSING",
|
|
329
|
+
}),
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const projectRoot = yield* getProjectRoot();
|
|
334
|
+
const pendingPath = yield* getPendingPath(projectRoot);
|
|
335
|
+
const sessions = yield* readPending(pendingPath);
|
|
336
|
+
|
|
337
|
+
if (sessions.length === 0) {
|
|
338
|
+
return ok(undefined);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const updatedSessions = yield* closeStaleOpenSessions({ sessions });
|
|
342
|
+
const { org, repo } = yield* resolveRemote(opts?.remoteUrl);
|
|
343
|
+
const remaining = yield* syncSessions({
|
|
344
|
+
sessions: updatedSessions,
|
|
345
|
+
workerUrl: config.worker_url,
|
|
346
|
+
token: config.token,
|
|
347
|
+
org,
|
|
348
|
+
repo,
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
yield* writePending({ path: pendingPath, sessions: remaining });
|
|
352
|
+
return ok(undefined);
|
|
353
|
+
});
|
|
354
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { capture } from "@/commands/capture";
|
|
5
|
+
import { hookClaudeCode } from "@/commands/hook";
|
|
6
|
+
import { init } from "@/commands/init";
|
|
7
|
+
import { login } from "@/commands/login";
|
|
8
|
+
import { push } from "@/commands/push";
|
|
9
|
+
import { sessionEnd } from "@/commands/session-end";
|
|
10
|
+
import { sessionStart } from "@/commands/session-start";
|
|
11
|
+
import { setup } from "@/commands/setup";
|
|
12
|
+
import { sync } from "@/commands/sync";
|
|
13
|
+
import { wrapCommand, wrapHookCommand } from "@/utils/errors";
|
|
14
|
+
|
|
15
|
+
const program = new Command();
|
|
16
|
+
|
|
17
|
+
program
|
|
18
|
+
.name("residue")
|
|
19
|
+
.description("Capture AI agent conversations linked to git commits")
|
|
20
|
+
.version("0.0.1");
|
|
21
|
+
|
|
22
|
+
program
|
|
23
|
+
.command("login")
|
|
24
|
+
.description("Save worker URL and auth token")
|
|
25
|
+
.requiredOption("--url <worker_url>", "Worker URL")
|
|
26
|
+
.requiredOption("--token <auth_token>", "Auth token")
|
|
27
|
+
.action(
|
|
28
|
+
wrapCommand((opts: { url: string; token: string }) =>
|
|
29
|
+
login({ url: opts.url, token: opts.token }),
|
|
30
|
+
),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
program
|
|
34
|
+
.command("init")
|
|
35
|
+
.description("Install git hooks in current repo")
|
|
36
|
+
.action(wrapCommand(() => init()));
|
|
37
|
+
|
|
38
|
+
program
|
|
39
|
+
.command("setup")
|
|
40
|
+
.description("Configure an agent adapter for this project")
|
|
41
|
+
.argument("<agent>", "Agent to set up (claude-code, pi)")
|
|
42
|
+
.action(wrapCommand((agent: string) => setup({ agent })));
|
|
43
|
+
|
|
44
|
+
const hook = program
|
|
45
|
+
.command("hook")
|
|
46
|
+
.description("Agent hook handlers (called by agent plugins)");
|
|
47
|
+
|
|
48
|
+
hook
|
|
49
|
+
.command("claude-code")
|
|
50
|
+
.description("Handle Claude Code hook events (reads JSON from stdin)")
|
|
51
|
+
.action(wrapHookCommand(() => hookClaudeCode()));
|
|
52
|
+
|
|
53
|
+
const session = program.command("session").description("Manage agent sessions");
|
|
54
|
+
|
|
55
|
+
session
|
|
56
|
+
.command("start")
|
|
57
|
+
.description("Start tracking an agent session")
|
|
58
|
+
.requiredOption("--agent <name>", "Agent name")
|
|
59
|
+
.requiredOption("--data <path>", "Path to raw session file")
|
|
60
|
+
.option("--agent-version <semver>", "Agent version", "unknown")
|
|
61
|
+
.action(
|
|
62
|
+
wrapCommand((opts: { agent: string; data: string; agentVersion: string }) =>
|
|
63
|
+
sessionStart({
|
|
64
|
+
agent: opts.agent,
|
|
65
|
+
data: opts.data,
|
|
66
|
+
agentVersion: opts.agentVersion,
|
|
67
|
+
}),
|
|
68
|
+
),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
session
|
|
72
|
+
.command("end")
|
|
73
|
+
.description("Mark an agent session as ended")
|
|
74
|
+
.requiredOption("--id <session-id>", "Session ID to end")
|
|
75
|
+
.action(wrapCommand((opts: { id: string }) => sessionEnd({ id: opts.id })));
|
|
76
|
+
|
|
77
|
+
program
|
|
78
|
+
.command("capture")
|
|
79
|
+
.description(
|
|
80
|
+
"Tag pending sessions with current commit SHA (called by post-commit hook)",
|
|
81
|
+
)
|
|
82
|
+
.action(wrapHookCommand(() => capture()));
|
|
83
|
+
|
|
84
|
+
program
|
|
85
|
+
.command("sync")
|
|
86
|
+
.description("Upload pending sessions to worker (called by pre-push hook)")
|
|
87
|
+
.option("--remote-url <url>", "Remote URL (passed by pre-push hook)")
|
|
88
|
+
.action(
|
|
89
|
+
wrapHookCommand((opts: { remoteUrl?: string }) =>
|
|
90
|
+
sync({ remoteUrl: opts.remoteUrl }),
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
program
|
|
95
|
+
.command("push")
|
|
96
|
+
.description("Upload pending sessions to worker (manual trigger)")
|
|
97
|
+
.action(wrapCommand(() => push()));
|
|
98
|
+
|
|
99
|
+
program.parse();
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config management for the residue CLI.
|
|
3
|
+
* Manages ~/.residue/config (JSON file).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { mkdir } from "fs/promises";
|
|
7
|
+
import { ResultAsync } from "neverthrow";
|
|
8
|
+
import { join } from "path";
|
|
9
|
+
import type { CliError } from "@/utils/errors";
|
|
10
|
+
import { toCliError } from "@/utils/errors";
|
|
11
|
+
|
|
12
|
+
export type ResidueConfig = {
|
|
13
|
+
worker_url: string;
|
|
14
|
+
token: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function home(): string {
|
|
18
|
+
return process.env.HOME || process.env.USERPROFILE || "/";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getConfigDir(): string {
|
|
22
|
+
return join(home(), ".residue");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getConfigPath(): string {
|
|
26
|
+
return join(getConfigDir(), "config");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function readConfig(): ResultAsync<ResidueConfig | null, CliError> {
|
|
30
|
+
return ResultAsync.fromPromise(
|
|
31
|
+
(async () => {
|
|
32
|
+
const path = getConfigPath();
|
|
33
|
+
const file = Bun.file(path);
|
|
34
|
+
const isExists = await file.exists();
|
|
35
|
+
if (!isExists) return null;
|
|
36
|
+
const text = await file.text();
|
|
37
|
+
return JSON.parse(text) as ResidueConfig;
|
|
38
|
+
})(),
|
|
39
|
+
toCliError({ message: "Failed to read config", code: "CONFIG_ERROR" }),
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function writeConfig(
|
|
44
|
+
config: ResidueConfig,
|
|
45
|
+
): ResultAsync<void, CliError> {
|
|
46
|
+
return ResultAsync.fromPromise(
|
|
47
|
+
(async () => {
|
|
48
|
+
const dir = getConfigDir();
|
|
49
|
+
await mkdir(dir, { recursive: true });
|
|
50
|
+
await Bun.write(getConfigPath(), JSON.stringify(config, null, 2));
|
|
51
|
+
})(),
|
|
52
|
+
toCliError({ message: "Failed to write config", code: "CONFIG_ERROR" }),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function configExists(): ResultAsync<boolean, CliError> {
|
|
57
|
+
return ResultAsync.fromPromise(
|
|
58
|
+
Bun.file(getConfigPath()).exists(),
|
|
59
|
+
toCliError({ message: "Failed to check config", code: "CONFIG_ERROR" }),
|
|
60
|
+
);
|
|
61
|
+
}
|
package/src/lib/git.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git utility functions for the residue CLI.
|
|
3
|
+
*/
|
|
4
|
+
import { err, ok, type Result, ResultAsync } from "neverthrow";
|
|
5
|
+
import { CliError, toCliError } from "@/utils/errors";
|
|
6
|
+
|
|
7
|
+
function runGitCommand(opts: {
|
|
8
|
+
args: string[];
|
|
9
|
+
errorMessage: string;
|
|
10
|
+
}): ResultAsync<string, CliError> {
|
|
11
|
+
return ResultAsync.fromPromise(
|
|
12
|
+
(async () => {
|
|
13
|
+
const proc = Bun.spawn(["git", ...opts.args], {
|
|
14
|
+
stdout: "pipe",
|
|
15
|
+
stderr: "pipe",
|
|
16
|
+
});
|
|
17
|
+
const exitCode = await proc.exited;
|
|
18
|
+
if (exitCode !== 0) {
|
|
19
|
+
const stderr = await new Response(proc.stderr).text();
|
|
20
|
+
throw new Error(stderr.trim() || `exit code ${exitCode}`);
|
|
21
|
+
}
|
|
22
|
+
return (await new Response(proc.stdout).text()).trim();
|
|
23
|
+
})(),
|
|
24
|
+
toCliError({ message: opts.errorMessage, code: "GIT_ERROR" }),
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function parseRemote(
|
|
29
|
+
remoteUrl: string,
|
|
30
|
+
): Result<{ org: string; repo: string }, CliError> {
|
|
31
|
+
const match = remoteUrl.match(/[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
32
|
+
if (!match) {
|
|
33
|
+
return err(
|
|
34
|
+
new CliError({
|
|
35
|
+
message: `Cannot parse git remote URL: ${remoteUrl}`,
|
|
36
|
+
code: "GIT_PARSE_ERROR",
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return ok({ org: match[1], repo: match[2] });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function getRemoteUrl(): ResultAsync<string, CliError> {
|
|
44
|
+
return runGitCommand({
|
|
45
|
+
args: ["remote", "get-url", "origin"],
|
|
46
|
+
errorMessage: "Failed to get git remote URL",
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getCurrentBranch(): ResultAsync<string, CliError> {
|
|
51
|
+
return runGitCommand({
|
|
52
|
+
args: ["rev-parse", "--abbrev-ref", "HEAD"],
|
|
53
|
+
errorMessage: "Failed to get current branch",
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getCurrentSha(): ResultAsync<string, CliError> {
|
|
58
|
+
return runGitCommand({
|
|
59
|
+
args: ["rev-parse", "HEAD"],
|
|
60
|
+
errorMessage: "Failed to get current commit SHA",
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function getCommitMeta(
|
|
65
|
+
sha: string,
|
|
66
|
+
): ResultAsync<
|
|
67
|
+
{ message: string; author: string; committed_at: number },
|
|
68
|
+
CliError
|
|
69
|
+
> {
|
|
70
|
+
return runGitCommand({
|
|
71
|
+
args: ["log", "-1", "--format=%s%n%an%n%ct", sha],
|
|
72
|
+
errorMessage: `Failed to get commit metadata for ${sha}`,
|
|
73
|
+
}).map((text) => {
|
|
74
|
+
const lines = text.split("\n");
|
|
75
|
+
return {
|
|
76
|
+
message: lines[0] || "",
|
|
77
|
+
author: lines[1] || "",
|
|
78
|
+
committed_at: parseInt(lines[2] || "0", 10),
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function isGitRepo(): ResultAsync<boolean, CliError> {
|
|
84
|
+
return ResultAsync.fromPromise(
|
|
85
|
+
(async () => {
|
|
86
|
+
const proc = Bun.spawn(["git", "rev-parse", "--git-dir"], {
|
|
87
|
+
stdout: "pipe",
|
|
88
|
+
stderr: "pipe",
|
|
89
|
+
});
|
|
90
|
+
const exitCode = await proc.exited;
|
|
91
|
+
return exitCode === 0;
|
|
92
|
+
})(),
|
|
93
|
+
toCliError({ message: "Failed to check git repo", code: "GIT_ERROR" }),
|
|
94
|
+
);
|
|
95
|
+
}
|