cueloop 0.1.0-alpha.32 → 0.1.0-alpha.34
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/package.json +23 -23
- package/src/args.ts +4 -1
- package/src/main.ts +17 -6
- package/src/open-target.test.ts +21 -9
- package/src/open-target.ts +15 -4
- package/src/pr.ts +10 -1
- package/src/session-commands.ts +3 -1
- package/src/share-command.test.ts +53 -12
- package/src/share-command.ts +37 -8
- package/src/working-tree.ts +83 -10
package/package.json
CHANGED
|
@@ -1,37 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cueloop",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.34",
|
|
4
|
+
"description": "Terminal review surface for coding agents: annotate plans and diffs, return a verdict, send structured feedback back",
|
|
5
|
+
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/mmurakaru/cueloop/issues"
|
|
8
|
+
},
|
|
4
9
|
"license": "Apache-2.0",
|
|
5
|
-
"
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/mmurakaru/cueloop.git",
|
|
13
|
+
"directory": "packages/cli"
|
|
14
|
+
},
|
|
6
15
|
"bin": {
|
|
7
16
|
"cueloop": "./src/main.ts"
|
|
8
17
|
},
|
|
18
|
+
"files": [
|
|
19
|
+
"src",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
9
23
|
"exports": {
|
|
10
24
|
".": "./src/main.ts"
|
|
11
25
|
},
|
|
12
|
-
"dependencies": {
|
|
13
|
-
"@cueloop/schema": "0.1.0-alpha.32",
|
|
14
|
-
"@cueloop/daemon": "0.1.0-alpha.32",
|
|
15
|
-
"@cueloop/client": "0.1.0-alpha.32"
|
|
16
|
-
},
|
|
17
26
|
"publishConfig": {
|
|
18
27
|
"access": "public"
|
|
19
28
|
},
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@cueloop/client": "0.1.0-alpha.34",
|
|
31
|
+
"@cueloop/daemon": "0.1.0-alpha.34",
|
|
32
|
+
"@cueloop/schema": "0.1.0-alpha.34"
|
|
33
|
+
},
|
|
24
34
|
"engines": {
|
|
25
35
|
"bun": ">=1.3.0"
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
"type": "git",
|
|
29
|
-
"url": "git+https://github.com/mmurakaru/cueloop.git",
|
|
30
|
-
"directory": "packages/cli"
|
|
31
|
-
},
|
|
32
|
-
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
33
|
-
"bugs": {
|
|
34
|
-
"url": "https://github.com/mmurakaru/cueloop/issues"
|
|
35
|
-
},
|
|
36
|
-
"description": "Terminal review surface for coding agents: annotate plans and diffs, return a verdict, send structured feedback back"
|
|
37
|
-
}
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/args.ts
CHANGED
|
@@ -31,7 +31,10 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
|
|
31
31
|
return { positional, flags };
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
export function stringFlag(
|
|
34
|
+
export function stringFlag(
|
|
35
|
+
flags: Record<string, string | boolean>,
|
|
36
|
+
key: string,
|
|
37
|
+
): string | undefined {
|
|
35
38
|
const value = flags[key];
|
|
36
39
|
return typeof value === "string" ? value : undefined;
|
|
37
40
|
}
|
package/src/main.ts
CHANGED
|
@@ -77,8 +77,12 @@ async function main(): Promise<number> {
|
|
|
77
77
|
const { positional, flags } = parseArgs(argv.slice(1));
|
|
78
78
|
const { shareCommand, sharePullCommand } = await import("./share-command");
|
|
79
79
|
const port = stringFlag(flags, "port");
|
|
80
|
-
const target = {
|
|
81
|
-
|
|
80
|
+
const target = {
|
|
81
|
+
host: stringFlag(flags, "host"),
|
|
82
|
+
port: port !== undefined ? Number(port) : undefined,
|
|
83
|
+
};
|
|
84
|
+
if (positional[0] === "pull")
|
|
85
|
+
return sharePullCommand({ ...target, sessionId: positional[1] });
|
|
82
86
|
return shareCommand({ ...target, sessionId: positional[0] });
|
|
83
87
|
}
|
|
84
88
|
case "review":
|
|
@@ -115,7 +119,9 @@ async function main(): Promise<number> {
|
|
|
115
119
|
* latest pending review.
|
|
116
120
|
*/
|
|
117
121
|
function openSelector(parsed: ParsedArgs): string | undefined {
|
|
118
|
-
return
|
|
122
|
+
return (
|
|
123
|
+
parsed.positional[0] ?? stringFlag(parsed.flags, "open") ?? stringFlag(parsed.flags, "latest")
|
|
124
|
+
);
|
|
119
125
|
}
|
|
120
126
|
|
|
121
127
|
/**
|
|
@@ -167,7 +173,7 @@ async function diffCommand(argv: string[]): Promise<number> {
|
|
|
167
173
|
|
|
168
174
|
const workspace = await resolveWorkspace();
|
|
169
175
|
const diff = await workingTreeDiff();
|
|
170
|
-
if (!diff.trim()) {
|
|
176
|
+
if (!diff.patch.trim()) {
|
|
171
177
|
return openReviewOfKind(
|
|
172
178
|
isDiffReview,
|
|
173
179
|
"diff",
|
|
@@ -178,7 +184,8 @@ async function diffCommand(argv: string[]): Promise<number> {
|
|
|
178
184
|
const client = await DaemonClient.connect({ autostart: true });
|
|
179
185
|
const review = await openReview(client, {
|
|
180
186
|
type: "diff",
|
|
181
|
-
content: diff,
|
|
187
|
+
content: diff.patch,
|
|
188
|
+
files: diff.files,
|
|
182
189
|
workspace,
|
|
183
190
|
title: `working tree @ ${workspace.branch}`,
|
|
184
191
|
});
|
|
@@ -202,7 +209,11 @@ async function reviewEntry(argv: string[]): Promise<number> {
|
|
|
202
209
|
const { reviewCommand } = await import("./pr");
|
|
203
210
|
return reviewCommand(argv);
|
|
204
211
|
}
|
|
205
|
-
return openReviewOfKind(
|
|
212
|
+
return openReviewOfKind(
|
|
213
|
+
isPrReview,
|
|
214
|
+
"PR",
|
|
215
|
+
explicitOpen || looksLikeSessionId ? selector : undefined,
|
|
216
|
+
);
|
|
206
217
|
}
|
|
207
218
|
|
|
208
219
|
async function runTui(sessionId?: string): Promise<number> {
|
package/src/open-target.test.ts
CHANGED
|
@@ -30,7 +30,10 @@ function session(overrides: {
|
|
|
30
30
|
},
|
|
31
31
|
revisions: [],
|
|
32
32
|
annotations: [],
|
|
33
|
-
verdict:
|
|
33
|
+
verdict:
|
|
34
|
+
overrides.status === "resolved"
|
|
35
|
+
? { kind: "approve", summary: "", feedback: "", resolvedAt: "" }
|
|
36
|
+
: null,
|
|
34
37
|
status: overrides.status ?? "pending",
|
|
35
38
|
createdAt: overrides.createdAt ?? "2026-01-01T00:00:00.000Z",
|
|
36
39
|
};
|
|
@@ -84,7 +87,10 @@ describe("resolveOpenTarget: latest pending default", () => {
|
|
|
84
87
|
];
|
|
85
88
|
|
|
86
89
|
// Assert
|
|
87
|
-
expect(resolveOpenTarget(sessions, { match: isPlan })).toEqual({
|
|
90
|
+
expect(resolveOpenTarget(sessions, { match: isPlan })).toEqual({
|
|
91
|
+
kind: "session",
|
|
92
|
+
sessionId: "ses_new",
|
|
93
|
+
});
|
|
88
94
|
});
|
|
89
95
|
|
|
90
96
|
test("ignores resolved sessions and other artifact types", () => {
|
|
@@ -96,7 +102,10 @@ describe("resolveOpenTarget: latest pending default", () => {
|
|
|
96
102
|
];
|
|
97
103
|
|
|
98
104
|
// Assert
|
|
99
|
-
expect(resolveOpenTarget(sessions, { match: isPlan })).toEqual({
|
|
105
|
+
expect(resolveOpenTarget(sessions, { match: isPlan })).toEqual({
|
|
106
|
+
kind: "session",
|
|
107
|
+
sessionId: "ses_plan",
|
|
108
|
+
});
|
|
100
109
|
});
|
|
101
110
|
|
|
102
111
|
test("no pending session in scope reports no-pending", () => {
|
|
@@ -111,10 +120,7 @@ describe("resolveOpenTarget: latest pending default", () => {
|
|
|
111
120
|
describe("resolveOpenTarget: by id", () => {
|
|
112
121
|
test("an exact session id in scope opens that session, pending or not", () => {
|
|
113
122
|
// Arrange
|
|
114
|
-
const sessions = [
|
|
115
|
-
session({ id: "ses_a" }),
|
|
116
|
-
session({ id: "ses_b", status: "resolved" }),
|
|
117
|
-
];
|
|
123
|
+
const sessions = [session({ id: "ses_a" }), session({ id: "ses_b", status: "resolved" })];
|
|
118
124
|
|
|
119
125
|
// Assert
|
|
120
126
|
expect(resolveOpenTarget(sessions, { match: isPlan, selector: "ses_b" })).toEqual({
|
|
@@ -193,7 +199,9 @@ describe("resolveOpenTarget: fuzzy by title", () => {
|
|
|
193
199
|
|
|
194
200
|
describe("openTargetMessage", () => {
|
|
195
201
|
test("no-pending mirrors the clean-tree tone", () => {
|
|
196
|
-
expect(openTargetMessage("plan", { kind: "no-pending" })).toBe(
|
|
202
|
+
expect(openTargetMessage("plan", { kind: "no-pending" })).toBe(
|
|
203
|
+
"no pending plan review - nothing to open",
|
|
204
|
+
);
|
|
197
205
|
});
|
|
198
206
|
|
|
199
207
|
test("no-match names the selector", () => {
|
|
@@ -204,7 +212,11 @@ describe("openTargetMessage", () => {
|
|
|
204
212
|
|
|
205
213
|
test("ambiguous lists the candidate titles", () => {
|
|
206
214
|
// Act
|
|
207
|
-
const message = openTargetMessage("plan", {
|
|
215
|
+
const message = openTargetMessage("plan", {
|
|
216
|
+
kind: "ambiguous",
|
|
217
|
+
selector: "rollout",
|
|
218
|
+
titles: ["A", "B"],
|
|
219
|
+
});
|
|
208
220
|
|
|
209
221
|
// Assert
|
|
210
222
|
expect(message).toContain('"rollout" matches several plan reviews');
|
package/src/open-target.ts
CHANGED
|
@@ -62,8 +62,12 @@ export function resolveOpenTarget(sessions: ReviewSession[], query: OpenTargetQu
|
|
|
62
62
|
const scoped = sessions.filter(query.match);
|
|
63
63
|
|
|
64
64
|
if (query.selector === undefined) {
|
|
65
|
-
const latestPending = scoped
|
|
66
|
-
|
|
65
|
+
const latestPending = scoped
|
|
66
|
+
.filter((candidate) => candidate.status === "pending")
|
|
67
|
+
.sort(newestFirst)[0];
|
|
68
|
+
return latestPending
|
|
69
|
+
? { kind: "session", sessionId: latestPending.id }
|
|
70
|
+
: { kind: "no-pending" };
|
|
67
71
|
}
|
|
68
72
|
|
|
69
73
|
const selector = query.selector;
|
|
@@ -83,7 +87,11 @@ export function resolveOpenTarget(sessions: ReviewSession[], query: OpenTargetQu
|
|
|
83
87
|
.sort(newestFirst);
|
|
84
88
|
if (substringMatches.length === 1) return { kind: "session", sessionId: substringMatches[0]!.id };
|
|
85
89
|
if (substringMatches.length === 0) return { kind: "no-match", selector };
|
|
86
|
-
return {
|
|
90
|
+
return {
|
|
91
|
+
kind: "ambiguous",
|
|
92
|
+
selector,
|
|
93
|
+
titles: substringMatches.map((candidate) => candidate.artifact.meta.title!),
|
|
94
|
+
};
|
|
87
95
|
}
|
|
88
96
|
|
|
89
97
|
/**
|
|
@@ -91,7 +99,10 @@ export function resolveOpenTarget(sessions: ReviewSession[], query: OpenTargetQu
|
|
|
91
99
|
* to review" tone. `label` is the human word for the verb's scope ("plan",
|
|
92
100
|
* "diff", "PR"). The "session" outcome has no message - the caller opens it.
|
|
93
101
|
*/
|
|
94
|
-
export function openTargetMessage(
|
|
102
|
+
export function openTargetMessage(
|
|
103
|
+
label: string,
|
|
104
|
+
target: Exclude<OpenTarget, { kind: "session" }>,
|
|
105
|
+
): string {
|
|
95
106
|
switch (target.kind) {
|
|
96
107
|
case "no-pending":
|
|
97
108
|
return `no pending ${label} review - nothing to open`;
|
package/src/pr.ts
CHANGED
|
@@ -56,6 +56,8 @@ export async function reviewCommand(argv: string[]): Promise<number> {
|
|
|
56
56
|
return 1;
|
|
57
57
|
}
|
|
58
58
|
const client = await DaemonClient.connect({ autostart: true });
|
|
59
|
+
// A PR diff is a partial patch with no full file contents, so no `files` here;
|
|
60
|
+
// hunk curation stays disabled for PR reviews (see diff-hunk-curate.ts).
|
|
59
61
|
const review = await openReview(client, {
|
|
60
62
|
type: "diff",
|
|
61
63
|
content: diff.stdout,
|
|
@@ -109,7 +111,14 @@ async function getSession(id: string): Promise<ReviewSession> {
|
|
|
109
111
|
/** Post the resolved session's verdict to the PR: feedback.md is the review body. */
|
|
110
112
|
async function postVerdict(session: ReviewSession, pr: string): Promise<number> {
|
|
111
113
|
const verdict = session.verdict!;
|
|
112
|
-
const result = await gh([
|
|
114
|
+
const result = await gh([
|
|
115
|
+
"pr",
|
|
116
|
+
"review",
|
|
117
|
+
pr,
|
|
118
|
+
VERDICT_FLAG[verdict.kind],
|
|
119
|
+
"--body",
|
|
120
|
+
verdict.feedback,
|
|
121
|
+
]);
|
|
113
122
|
if (result.code !== 0) {
|
|
114
123
|
console.error(result.stderr.trim() || `gh pr review ${pr} failed (exit ${result.code})`);
|
|
115
124
|
return 1;
|
package/src/session-commands.ts
CHANGED
|
@@ -29,7 +29,9 @@ export async function sessionCommand(argv: string[]): Promise<number> {
|
|
|
29
29
|
const content = contentFile ? await Bun.file(contentFile).text() : await readStdin();
|
|
30
30
|
// per-file agent notes for diff sessions: a JSON array of { path, body }
|
|
31
31
|
const notesFile = stringFlag(flags, "notes-file");
|
|
32
|
-
const notes = notesFile
|
|
32
|
+
const notes = notesFile
|
|
33
|
+
? (JSON.parse(await Bun.file(notesFile).text()) as ReviewNote[])
|
|
34
|
+
: undefined;
|
|
33
35
|
const review = await openReview(client, {
|
|
34
36
|
type: (stringFlag(flags, "type") ?? "plan") as ArtifactType,
|
|
35
37
|
content,
|
|
@@ -19,7 +19,13 @@ function sessionFixture(id: string, overrides: Partial<ReviewSession> = {}): Rev
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
function annotationFixture(id: string, author?: string): Annotation {
|
|
22
|
-
const base: Annotation = {
|
|
22
|
+
const base: Annotation = {
|
|
23
|
+
id,
|
|
24
|
+
kind: "comment",
|
|
25
|
+
anchor: { quote: "Plan", prefix: "", suffix: "" },
|
|
26
|
+
body: "note",
|
|
27
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
28
|
+
};
|
|
23
29
|
return author ? { ...base, author } : base;
|
|
24
30
|
}
|
|
25
31
|
|
|
@@ -36,7 +42,8 @@ function fakeClient(sessions: ReviewSession[]): SessionClient {
|
|
|
36
42
|
sessionMergeShared: mock(async (id: string, incoming: { annotations: Annotation[] }) => {
|
|
37
43
|
const session = sessions.find((candidate) => candidate.id === id)!;
|
|
38
44
|
const known = new Set(session.annotations.map((annotation) => annotation.id));
|
|
39
|
-
for (const annotation of incoming.annotations)
|
|
45
|
+
for (const annotation of incoming.annotations)
|
|
46
|
+
if (!known.has(annotation.id)) session.annotations.push(annotation);
|
|
40
47
|
return session;
|
|
41
48
|
}),
|
|
42
49
|
} as unknown as SessionClient;
|
|
@@ -55,11 +62,20 @@ function depsSpy(overrides: Partial<ShareDeps> = {}): ShareDeps & { lines: strin
|
|
|
55
62
|
describe(shareSession, () => {
|
|
56
63
|
test("publishes the named session and reports the copied ssh line", async () => {
|
|
57
64
|
// Arrange
|
|
58
|
-
const publish = mock(
|
|
65
|
+
const publish = mock(
|
|
66
|
+
async (session: ReviewSession) => (
|
|
67
|
+
expect(session.id).toBe("ses_2"),
|
|
68
|
+
{ line: "ssh p_abc123xy@cueloop.dev", copied: true }
|
|
69
|
+
),
|
|
70
|
+
);
|
|
59
71
|
const deps = depsSpy({ publish });
|
|
60
72
|
|
|
61
73
|
// Act
|
|
62
|
-
const code = await shareSession(
|
|
74
|
+
const code = await shareSession(
|
|
75
|
+
fakeClient([sessionFixture("ses_1"), sessionFixture("ses_2")]),
|
|
76
|
+
{ sessionId: "ses_2" },
|
|
77
|
+
deps,
|
|
78
|
+
);
|
|
63
79
|
|
|
64
80
|
// Assert
|
|
65
81
|
expect(code).toBe(0);
|
|
@@ -68,7 +84,12 @@ describe(shareSession, () => {
|
|
|
68
84
|
|
|
69
85
|
test("without an id, shares the most recent session", async () => {
|
|
70
86
|
// Arrange
|
|
71
|
-
const publish = mock(
|
|
87
|
+
const publish = mock(
|
|
88
|
+
async (session: ReviewSession) => (
|
|
89
|
+
expect(session.id).toBe("ses_2"),
|
|
90
|
+
{ line: "ssh p_zzzzzzzz@cueloop.dev", copied: true }
|
|
91
|
+
),
|
|
92
|
+
);
|
|
72
93
|
const deps = depsSpy({ publish });
|
|
73
94
|
|
|
74
95
|
// Act
|
|
@@ -80,7 +101,9 @@ describe(shareSession, () => {
|
|
|
80
101
|
|
|
81
102
|
test("prints the bare line when the clipboard is unavailable", async () => {
|
|
82
103
|
// Arrange
|
|
83
|
-
const deps = depsSpy({
|
|
104
|
+
const deps = depsSpy({
|
|
105
|
+
publish: mock(async () => ({ line: "ssh p_abc123xy@cueloop.dev", copied: false })),
|
|
106
|
+
});
|
|
84
107
|
|
|
85
108
|
// Act
|
|
86
109
|
await shareSession(fakeClient([sessionFixture("ses_1")]), { sessionId: "ses_1" }, deps);
|
|
@@ -118,15 +141,24 @@ describe(shareSession, () => {
|
|
|
118
141
|
|
|
119
142
|
function pullDepsSpy(remote: ReviewSession): PullDeps & { lines: string[] } {
|
|
120
143
|
const lines: string[] = [];
|
|
121
|
-
return {
|
|
144
|
+
return {
|
|
145
|
+
pull: mock(async () => remote),
|
|
146
|
+
out: (message: string) => void lines.push(message),
|
|
147
|
+
lines,
|
|
148
|
+
};
|
|
122
149
|
}
|
|
123
150
|
|
|
124
151
|
describe(pullSession, () => {
|
|
125
152
|
test("unions collaborator notes in, ignoring the planner's own unauthored notes", async () => {
|
|
126
153
|
// Arrange - the planner's own note carries no author; the collaborator's does
|
|
127
|
-
const local = sessionFixture("ses_1", {
|
|
154
|
+
const local = sessionFixture("ses_1", {
|
|
155
|
+
shareId: "p_abc123xy",
|
|
156
|
+
annotations: [annotationFixture("mine")],
|
|
157
|
+
});
|
|
128
158
|
const client = fakeClient([local]);
|
|
129
|
-
const remote = sessionFixture("ses_1", {
|
|
159
|
+
const remote = sessionFixture("ses_1", {
|
|
160
|
+
annotations: [annotationFixture("mine"), annotationFixture("theirs", "SHA256:mate")],
|
|
161
|
+
});
|
|
130
162
|
const deps = pullDepsSpy(remote);
|
|
131
163
|
|
|
132
164
|
// Act
|
|
@@ -154,8 +186,13 @@ describe(pullSession, () => {
|
|
|
154
186
|
|
|
155
187
|
test("reports when nothing new came back", async () => {
|
|
156
188
|
// Arrange
|
|
157
|
-
const local = sessionFixture("ses_1", {
|
|
158
|
-
|
|
189
|
+
const local = sessionFixture("ses_1", {
|
|
190
|
+
shareId: "p_abc123xy",
|
|
191
|
+
annotations: [annotationFixture("theirs", "SHA256:mate")],
|
|
192
|
+
});
|
|
193
|
+
const deps = pullDepsSpy(
|
|
194
|
+
sessionFixture("ses_1", { annotations: [annotationFixture("theirs", "SHA256:mate")] }),
|
|
195
|
+
);
|
|
159
196
|
|
|
160
197
|
// Act
|
|
161
198
|
await pullSession(fakeClient([local]), { sessionId: "ses_1" }, deps);
|
|
@@ -169,7 +206,11 @@ describe(pullSession, () => {
|
|
|
169
206
|
const deps = pullDepsSpy(sessionFixture("ses_1"));
|
|
170
207
|
|
|
171
208
|
// Act
|
|
172
|
-
const code = await pullSession(
|
|
209
|
+
const code = await pullSession(
|
|
210
|
+
fakeClient([sessionFixture("ses_1")]),
|
|
211
|
+
{ sessionId: "ses_1" },
|
|
212
|
+
deps,
|
|
213
|
+
);
|
|
173
214
|
|
|
174
215
|
// Assert
|
|
175
216
|
expect(code).toBe(1);
|
package/src/share-command.ts
CHANGED
|
@@ -7,7 +7,14 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { DaemonClient, type SessionClient } from "@cueloop/daemon/client";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
collaboratorAnnotations,
|
|
12
|
+
publishShare,
|
|
13
|
+
pullShare,
|
|
14
|
+
shareIdFromLine,
|
|
15
|
+
type ShareResult,
|
|
16
|
+
type ShareTarget,
|
|
17
|
+
} from "@cueloop/client";
|
|
11
18
|
import type { ReviewSession } from "@cueloop/schema";
|
|
12
19
|
|
|
13
20
|
export interface ShareParams {
|
|
@@ -31,7 +38,10 @@ const defaultDeps: ShareDeps = { publish: publishShare, out: (message) => consol
|
|
|
31
38
|
const defaultPullDeps: PullDeps = { pull: pullShare, out: (message) => console.log(message) };
|
|
32
39
|
|
|
33
40
|
/** Connect to the local daemon, then share the chosen session. */
|
|
34
|
-
export async function shareCommand(
|
|
41
|
+
export async function shareCommand(
|
|
42
|
+
params: ShareParams,
|
|
43
|
+
deps: ShareDeps = defaultDeps,
|
|
44
|
+
): Promise<number> {
|
|
35
45
|
const client = await DaemonClient.connect({ home: params.home, autostart: true });
|
|
36
46
|
try {
|
|
37
47
|
return await shareSession(client, params, deps);
|
|
@@ -41,7 +51,10 @@ export async function shareCommand(params: ShareParams, deps: ShareDeps = defaul
|
|
|
41
51
|
}
|
|
42
52
|
|
|
43
53
|
/** Connect to the local daemon, then pull collaborator notes for the plan. */
|
|
44
|
-
export async function sharePullCommand(
|
|
54
|
+
export async function sharePullCommand(
|
|
55
|
+
params: ShareParams,
|
|
56
|
+
deps: PullDeps = defaultPullDeps,
|
|
57
|
+
): Promise<number> {
|
|
45
58
|
const client = await DaemonClient.connect({ home: params.home, autostart: true });
|
|
46
59
|
try {
|
|
47
60
|
return await pullSession(client, params, deps);
|
|
@@ -51,7 +64,11 @@ export async function sharePullCommand(params: ShareParams, deps: PullDeps = def
|
|
|
51
64
|
}
|
|
52
65
|
|
|
53
66
|
/** The share orchestration, over any SessionClient - the seam the tests drive. */
|
|
54
|
-
export async function shareSession(
|
|
67
|
+
export async function shareSession(
|
|
68
|
+
client: SessionClient,
|
|
69
|
+
params: ShareParams,
|
|
70
|
+
deps: ShareDeps,
|
|
71
|
+
): Promise<number> {
|
|
55
72
|
const session = await pickSession(client, params.sessionId);
|
|
56
73
|
if (!session) {
|
|
57
74
|
deps.out("no plan to share - open a review first");
|
|
@@ -69,7 +86,11 @@ export async function shareSession(client: SessionClient, params: ShareParams, d
|
|
|
69
86
|
* plan. Union-by-id keeps the planner's own notes; only collaborators' new
|
|
70
87
|
* ones land. The gateway lets only the fingerprint that shared it pull.
|
|
71
88
|
*/
|
|
72
|
-
export async function pullSession(
|
|
89
|
+
export async function pullSession(
|
|
90
|
+
client: SessionClient,
|
|
91
|
+
params: ShareParams,
|
|
92
|
+
deps: PullDeps,
|
|
93
|
+
): Promise<number> {
|
|
73
94
|
const session = await pickSharedSession(client, params.sessionId);
|
|
74
95
|
if (!session) {
|
|
75
96
|
deps.out("no shared plan to pull - run `cueloop share` first");
|
|
@@ -82,19 +103,27 @@ export async function pullSession(client: SessionClient, params: ShareParams, de
|
|
|
82
103
|
participants: remote.participants,
|
|
83
104
|
});
|
|
84
105
|
const added = merged.annotations.length - before;
|
|
85
|
-
deps.out(
|
|
106
|
+
deps.out(
|
|
107
|
+
added > 0 ? `pulled ${added} new annotation${added === 1 ? "" : "s"}` : "no new annotations",
|
|
108
|
+
);
|
|
86
109
|
return 0;
|
|
87
110
|
}
|
|
88
111
|
|
|
89
112
|
/** The named session, or the most recent one when no id is given. */
|
|
90
|
-
async function pickSession(
|
|
113
|
+
async function pickSession(
|
|
114
|
+
client: SessionClient,
|
|
115
|
+
sessionId?: string,
|
|
116
|
+
): Promise<ReviewSession | null> {
|
|
91
117
|
if (sessionId) return client.sessionGet(sessionId);
|
|
92
118
|
const sessions = await client.sessionList();
|
|
93
119
|
return sessions.at(-1) ?? null;
|
|
94
120
|
}
|
|
95
121
|
|
|
96
122
|
/** The named session (if shared), or the most recent shared one. */
|
|
97
|
-
async function pickSharedSession(
|
|
123
|
+
async function pickSharedSession(
|
|
124
|
+
client: SessionClient,
|
|
125
|
+
sessionId?: string,
|
|
126
|
+
): Promise<ReviewSession | null> {
|
|
98
127
|
if (sessionId) {
|
|
99
128
|
const session = await client.sessionGet(sessionId);
|
|
100
129
|
return session.shareId ? session : null;
|
package/src/working-tree.ts
CHANGED
|
@@ -1,27 +1,100 @@
|
|
|
1
1
|
/** Working-tree diff capture for `cueloop diff`, untracked files included. */
|
|
2
2
|
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { DiffFileContents } from "@cueloop/schema";
|
|
5
|
+
|
|
3
6
|
async function git(args: string[], cwd: string): Promise<string | null> {
|
|
4
|
-
const
|
|
5
|
-
const
|
|
6
|
-
if ((await
|
|
7
|
-
return
|
|
7
|
+
const gitProcess = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
|
|
8
|
+
const stdout = await new Response(gitProcess.stdout).text();
|
|
9
|
+
if ((await gitProcess.exited) !== 0) return null;
|
|
10
|
+
return stdout.trim();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The blob at HEAD for a path, or "" when the path is not in HEAD (new file). */
|
|
14
|
+
async function headContents(path: string, cwd: string): Promise<string> {
|
|
15
|
+
const gitProcess = Bun.spawn(["git", "show", `HEAD:${path}`], {
|
|
16
|
+
cwd,
|
|
17
|
+
stdout: "pipe",
|
|
18
|
+
stderr: "ignore",
|
|
19
|
+
});
|
|
20
|
+
const stdout = await new Response(gitProcess.stdout).text();
|
|
21
|
+
return (await gitProcess.exited) === 0 ? stdout : "";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The working-tree file, or "" when it is gone (deleted). */
|
|
25
|
+
async function workingContents(path: string, cwd: string): Promise<string> {
|
|
26
|
+
try {
|
|
27
|
+
return await Bun.file(join(cwd, path)).text();
|
|
28
|
+
} catch {
|
|
29
|
+
return "";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A NUL byte marks content git would treat as binary - not text-curatable. */
|
|
34
|
+
function looksBinary(text: string): boolean {
|
|
35
|
+
for (let index = 0; index < text.length; index++) {
|
|
36
|
+
if (text.charCodeAt(index) === 0) return true;
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Full old/new contents for every tracked change against HEAD, classified by
|
|
43
|
+
* git's own status letter. Renames/copies and binary files are omitted: a
|
|
44
|
+
* path+text pair cannot express a rename header or a binary blob, so curation
|
|
45
|
+
* would emit a patch that drops the old path or corrupts the file - leaving them
|
|
46
|
+
* out keeps those changes in the patch but simply not curatable. The
|
|
47
|
+
* NUL-delimited stream survives paths with spaces or unicode.
|
|
48
|
+
*/
|
|
49
|
+
async function trackedFileContents(cwd: string): Promise<DiffFileContents[]> {
|
|
50
|
+
const nameStatus = (await git(["diff", "HEAD", "--name-status", "-z"], cwd)) ?? "";
|
|
51
|
+
const tokens = nameStatus.split("\0").filter((token) => token.length > 0);
|
|
52
|
+
const files: DiffFileContents[] = [];
|
|
53
|
+
let cursor = 0;
|
|
54
|
+
while (cursor < tokens.length) {
|
|
55
|
+
const code = tokens[cursor++]![0];
|
|
56
|
+
if (code === "R" || code === "C") {
|
|
57
|
+
cursor += 2;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const path = tokens[cursor++]!;
|
|
61
|
+
const oldContents = code === "A" ? "" : await headContents(path, cwd);
|
|
62
|
+
const newContents = code === "D" ? "" : await workingContents(path, cwd);
|
|
63
|
+
if (looksBinary(oldContents) || looksBinary(newContents)) continue;
|
|
64
|
+
const status = code === "A" ? "added" : code === "D" ? "deleted" : "modified";
|
|
65
|
+
files.push({ path, oldContents, newContents, status });
|
|
66
|
+
}
|
|
67
|
+
return files;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface WorkingTreeDiff {
|
|
71
|
+
patch: string;
|
|
72
|
+
/** Full file contents per curatable changed file, keeping curation applyable. */
|
|
73
|
+
files: DiffFileContents[];
|
|
8
74
|
}
|
|
9
75
|
|
|
10
|
-
export async function workingTreeDiff(cwd = process.cwd()): Promise<
|
|
76
|
+
export async function workingTreeDiff(cwd = process.cwd()): Promise<WorkingTreeDiff> {
|
|
11
77
|
const tracked = (await git(["diff", "HEAD"], cwd)) ?? "";
|
|
78
|
+
const files = await trackedFileContents(cwd);
|
|
12
79
|
// untracked files included by default
|
|
13
80
|
const untrackedList = (await git(["ls-files", "--others", "--exclude-standard"], cwd)) ?? "";
|
|
14
81
|
let untracked = "";
|
|
15
82
|
for (const file of untrackedList.split("\n").filter(Boolean)) {
|
|
16
83
|
// `git diff --no-index` exits 1 when the files differ - that is success here
|
|
17
|
-
const
|
|
84
|
+
const gitProcess = Bun.spawn(["git", "diff", "--no-index", "--", "/dev/null", file], {
|
|
18
85
|
cwd,
|
|
19
86
|
stdout: "pipe",
|
|
20
87
|
stderr: "ignore",
|
|
21
88
|
});
|
|
22
|
-
const
|
|
23
|
-
const code = await
|
|
24
|
-
if ((code === 0 || code === 1) &&
|
|
89
|
+
const stdout = await new Response(gitProcess.stdout).text();
|
|
90
|
+
const code = await gitProcess.exited;
|
|
91
|
+
if ((code === 0 || code === 1) && stdout.trim()) {
|
|
92
|
+
untracked += stdout.trim() + "\n";
|
|
93
|
+
const newContents = await workingContents(file, cwd);
|
|
94
|
+
if (!looksBinary(newContents)) {
|
|
95
|
+
files.push({ path: file, oldContents: "", newContents, status: "added" });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
25
98
|
}
|
|
26
|
-
return [tracked, untracked].filter(Boolean).join("\n");
|
|
99
|
+
return { patch: [tracked, untracked].filter(Boolean).join("\n"), files };
|
|
27
100
|
}
|