cueloop 0.1.0-alpha.21 → 0.1.0-alpha.23
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 +4 -4
- package/src/main.ts +12 -0
- package/src/open-target.test.ts +34 -5
- package/src/session-commands.ts +6 -1
- package/src/share-command.test.ts +87 -0
- package/src/share-command.ts +54 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cueloop",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.23",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
".": "./src/main.ts"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
14
|
-
"@cueloop/daemon": "0.1.0-alpha.
|
|
15
|
-
"@cueloop/client": "0.1.0-alpha.
|
|
13
|
+
"@cueloop/schema": "0.1.0-alpha.23",
|
|
14
|
+
"@cueloop/daemon": "0.1.0-alpha.23",
|
|
15
|
+
"@cueloop/client": "0.1.0-alpha.23"
|
|
16
16
|
},
|
|
17
17
|
"publishConfig": {
|
|
18
18
|
"access": "public"
|
package/src/main.ts
CHANGED
|
@@ -72,6 +72,16 @@ async function main(): Promise<number> {
|
|
|
72
72
|
await new Promise(() => {}); // serve until signalled
|
|
73
73
|
return 0;
|
|
74
74
|
}
|
|
75
|
+
case "share": {
|
|
76
|
+
const { positional, flags } = parseArgs(argv.slice(1));
|
|
77
|
+
const { shareCommand } = await import("./share-command");
|
|
78
|
+
const port = stringFlag(flags, "port");
|
|
79
|
+
return shareCommand({
|
|
80
|
+
sessionId: positional[0],
|
|
81
|
+
host: stringFlag(flags, "host"),
|
|
82
|
+
port: port !== undefined ? Number(port) : undefined,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
75
85
|
case "review":
|
|
76
86
|
return reviewEntry(argv.slice(1));
|
|
77
87
|
case "review-post": {
|
|
@@ -213,6 +223,8 @@ function printHelp(): void {
|
|
|
213
223
|
" cueloop serve [session-id] share over ssh: observers are read-only,",
|
|
214
224
|
" you stay the controller (--port 2222, --host 127.0.0.1;",
|
|
215
225
|
" password-less - share the address deliberately)",
|
|
226
|
+
" cueloop share [session-id] hand a plan to a teammate: copies one",
|
|
227
|
+
" ssh line (--host cueloop.dev, --port 22)",
|
|
216
228
|
" cueloop session <verb> [flags] script the daemon (create|get|list|wait|annotate|resolve|submit-revision)",
|
|
217
229
|
" cueloop daemon run the daemon in the foreground",
|
|
218
230
|
].join("\n"),
|
package/src/open-target.test.ts
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Verb-first open resolution over a plain session list: latest-pending default,
|
|
3
|
-
* by-id, and fuzzy-by-title (exact wins, unique substring wins, else
|
|
4
|
-
* ambiguous). No daemon and no TUI - the resolver is pure over the array.
|
|
5
|
-
*/
|
|
1
|
+
/** Verb-first open resolution over a plain session list: latest-pending default, by-id, and fuzzy-by-title (exact wins, unique substring wins, else ambiguous). Pure over the array - no daemon, no TUI. */
|
|
6
2
|
|
|
7
3
|
import { describe, expect, test } from "bun:test";
|
|
8
4
|
import type { ArtifactType, ReviewSession } from "@cueloop/schema";
|
|
@@ -61,7 +57,10 @@ describe("verb scope predicates are disjoint", () => {
|
|
|
61
57
|
});
|
|
62
58
|
|
|
63
59
|
test("a PR review resolves under exactly one scope - never both diff and review", () => {
|
|
60
|
+
// Arrange
|
|
64
61
|
const prReview = session({ id: "ses_pr", type: "diff", pr: "42" });
|
|
62
|
+
|
|
63
|
+
// Assert
|
|
65
64
|
expect(isDiffReview(prReview)).toBe(false);
|
|
66
65
|
expect(isPrReview(prReview)).toBe(true);
|
|
67
66
|
});
|
|
@@ -77,35 +76,47 @@ describe("isSessionId", () => {
|
|
|
77
76
|
|
|
78
77
|
describe("resolveOpenTarget: latest pending default", () => {
|
|
79
78
|
test("picks the most recent pending session in scope", () => {
|
|
79
|
+
// Arrange
|
|
80
80
|
const sessions = [
|
|
81
81
|
session({ id: "ses_old", createdAt: "2026-01-01T00:00:00.000Z" }),
|
|
82
82
|
session({ id: "ses_new", createdAt: "2026-03-01T00:00:00.000Z" }),
|
|
83
83
|
session({ id: "ses_mid", createdAt: "2026-02-01T00:00:00.000Z" }),
|
|
84
84
|
];
|
|
85
|
+
|
|
86
|
+
// Assert
|
|
85
87
|
expect(resolveOpenTarget(sessions, { match: isPlan })).toEqual({ kind: "session", sessionId: "ses_new" });
|
|
86
88
|
});
|
|
87
89
|
|
|
88
90
|
test("ignores resolved sessions and other artifact types", () => {
|
|
91
|
+
// Arrange
|
|
89
92
|
const sessions = [
|
|
90
93
|
session({ id: "ses_resolved", createdAt: "2026-05-01T00:00:00.000Z", status: "resolved" }),
|
|
91
94
|
session({ id: "ses_diff", type: "diff", createdAt: "2026-04-01T00:00:00.000Z" }),
|
|
92
95
|
session({ id: "ses_plan", createdAt: "2026-02-01T00:00:00.000Z" }),
|
|
93
96
|
];
|
|
97
|
+
|
|
98
|
+
// Assert
|
|
94
99
|
expect(resolveOpenTarget(sessions, { match: isPlan })).toEqual({ kind: "session", sessionId: "ses_plan" });
|
|
95
100
|
});
|
|
96
101
|
|
|
97
102
|
test("no pending session in scope reports no-pending", () => {
|
|
103
|
+
// Arrange
|
|
98
104
|
const sessions = [session({ id: "ses_done", status: "resolved" })];
|
|
105
|
+
|
|
106
|
+
// Assert
|
|
99
107
|
expect(resolveOpenTarget(sessions, { match: isPlan })).toEqual({ kind: "no-pending" });
|
|
100
108
|
});
|
|
101
109
|
});
|
|
102
110
|
|
|
103
111
|
describe("resolveOpenTarget: by id", () => {
|
|
104
112
|
test("an exact session id in scope opens that session, pending or not", () => {
|
|
113
|
+
// Arrange
|
|
105
114
|
const sessions = [
|
|
106
115
|
session({ id: "ses_a" }),
|
|
107
116
|
session({ id: "ses_b", status: "resolved" }),
|
|
108
117
|
];
|
|
118
|
+
|
|
119
|
+
// Assert
|
|
109
120
|
expect(resolveOpenTarget(sessions, { match: isPlan, selector: "ses_b" })).toEqual({
|
|
110
121
|
kind: "session",
|
|
111
122
|
sessionId: "ses_b",
|
|
@@ -113,7 +124,10 @@ describe("resolveOpenTarget: by id", () => {
|
|
|
113
124
|
});
|
|
114
125
|
|
|
115
126
|
test("an id outside the scope does not match", () => {
|
|
127
|
+
// Arrange
|
|
116
128
|
const sessions = [session({ id: "ses_diff", type: "diff" })];
|
|
129
|
+
|
|
130
|
+
// Assert
|
|
117
131
|
expect(resolveOpenTarget(sessions, { match: isPlan, selector: "ses_diff" })).toEqual({
|
|
118
132
|
kind: "no-match",
|
|
119
133
|
selector: "ses_diff",
|
|
@@ -123,10 +137,13 @@ describe("resolveOpenTarget: by id", () => {
|
|
|
123
137
|
|
|
124
138
|
describe("resolveOpenTarget: fuzzy by title", () => {
|
|
125
139
|
test("case-insensitive exact title wins over substring rivals", () => {
|
|
140
|
+
// Arrange
|
|
126
141
|
const sessions = [
|
|
127
142
|
session({ id: "ses_exact", title: "Rollout" }),
|
|
128
143
|
session({ id: "ses_sub", title: "Rollout plan v2" }),
|
|
129
144
|
];
|
|
145
|
+
|
|
146
|
+
// Assert
|
|
130
147
|
expect(resolveOpenTarget(sessions, { match: isPlan, selector: "rollout" })).toEqual({
|
|
131
148
|
kind: "session",
|
|
132
149
|
sessionId: "ses_exact",
|
|
@@ -134,10 +151,13 @@ describe("resolveOpenTarget: fuzzy by title", () => {
|
|
|
134
151
|
});
|
|
135
152
|
|
|
136
153
|
test("a unique case-insensitive substring match wins", () => {
|
|
154
|
+
// Arrange
|
|
137
155
|
const sessions = [
|
|
138
156
|
session({ id: "ses_auth", title: "Auth rewrite" }),
|
|
139
157
|
session({ id: "ses_cache", title: "Cache warmup" }),
|
|
140
158
|
];
|
|
159
|
+
|
|
160
|
+
// Assert
|
|
141
161
|
expect(resolveOpenTarget(sessions, { match: isPlan, selector: "AUTH" })).toEqual({
|
|
142
162
|
kind: "session",
|
|
143
163
|
sessionId: "ses_auth",
|
|
@@ -145,10 +165,13 @@ describe("resolveOpenTarget: fuzzy by title", () => {
|
|
|
145
165
|
});
|
|
146
166
|
|
|
147
167
|
test("several substring matches are ambiguous and list the candidate titles", () => {
|
|
168
|
+
// Arrange
|
|
148
169
|
const sessions = [
|
|
149
170
|
session({ id: "ses_1", title: "Payments rollout" }),
|
|
150
171
|
session({ id: "ses_2", title: "Rollout of search" }),
|
|
151
172
|
];
|
|
173
|
+
|
|
174
|
+
// Assert
|
|
152
175
|
expect(resolveOpenTarget(sessions, { match: isPlan, selector: "rollout" })).toEqual({
|
|
153
176
|
kind: "ambiguous",
|
|
154
177
|
selector: "rollout",
|
|
@@ -157,7 +180,10 @@ describe("resolveOpenTarget: fuzzy by title", () => {
|
|
|
157
180
|
});
|
|
158
181
|
|
|
159
182
|
test("no title match reports no-match", () => {
|
|
183
|
+
// Arrange
|
|
160
184
|
const sessions = [session({ id: "ses_1", title: "Auth rewrite" })];
|
|
185
|
+
|
|
186
|
+
// Assert
|
|
161
187
|
expect(resolveOpenTarget(sessions, { match: isPlan, selector: "billing" })).toEqual({
|
|
162
188
|
kind: "no-match",
|
|
163
189
|
selector: "billing",
|
|
@@ -177,7 +203,10 @@ describe("openTargetMessage", () => {
|
|
|
177
203
|
});
|
|
178
204
|
|
|
179
205
|
test("ambiguous lists the candidate titles", () => {
|
|
206
|
+
// Act
|
|
180
207
|
const message = openTargetMessage("plan", { kind: "ambiguous", selector: "rollout", titles: ["A", "B"] });
|
|
208
|
+
|
|
209
|
+
// Assert
|
|
181
210
|
expect(message).toContain('"rollout" matches several plan reviews');
|
|
182
211
|
expect(message).toContain(" - A");
|
|
183
212
|
expect(message).toContain(" - B");
|
package/src/session-commands.ts
CHANGED
|
@@ -96,7 +96,12 @@ export async function sessionCommand(argv: string[]): Promise<number> {
|
|
|
96
96
|
const id = required(positional[1], "session id");
|
|
97
97
|
const contentFile = stringFlag(flags, "content-file");
|
|
98
98
|
const content = contentFile ? await Bun.file(contentFile).text() : await readStdin();
|
|
99
|
-
|
|
99
|
+
// --addressed a_1,a_2 - the annotation ids this revision acted on
|
|
100
|
+
const addressed = (stringFlag(flags, "addressed") ?? "")
|
|
101
|
+
.split(",")
|
|
102
|
+
.map((annotationId) => annotationId.trim())
|
|
103
|
+
.filter(Boolean);
|
|
104
|
+
out(await client.sessionSubmitRevision(id, content, addressed));
|
|
100
105
|
return 0;
|
|
101
106
|
}
|
|
102
107
|
default:
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { SCHEMA_VERSION, type ReviewSession } from "@cueloop/schema";
|
|
3
|
+
import type { SessionClient } from "@cueloop/daemon/client";
|
|
4
|
+
import { shareSession, type ShareDeps } from "./share-command";
|
|
5
|
+
|
|
6
|
+
function sessionFixture(id: string): ReviewSession {
|
|
7
|
+
return {
|
|
8
|
+
schemaVersion: SCHEMA_VERSION,
|
|
9
|
+
id,
|
|
10
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
11
|
+
artifact: { type: "plan", content: "# Plan\n", meta: {} },
|
|
12
|
+
revisions: [{ revision: 1, content: "# Plan\n", submittedAt: "2026-01-01T00:00:00.000Z" }],
|
|
13
|
+
annotations: [],
|
|
14
|
+
verdict: null,
|
|
15
|
+
status: "pending",
|
|
16
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A SessionClient that answers get/list from a fixed list. */
|
|
21
|
+
function fakeClient(sessions: ReviewSession[]): SessionClient {
|
|
22
|
+
return {
|
|
23
|
+
sessionGet: async (id: string) => sessions.find((session) => session.id === id)!,
|
|
24
|
+
sessionList: async () => sessions,
|
|
25
|
+
} as unknown as SessionClient;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function depsSpy(overrides: Partial<ShareDeps> = {}): ShareDeps & { lines: string[] } {
|
|
29
|
+
const lines: string[] = [];
|
|
30
|
+
return {
|
|
31
|
+
publish: mock(async () => ({ line: "ssh p_abc123xy@cueloop.dev", copied: true })),
|
|
32
|
+
out: (message: string) => void lines.push(message),
|
|
33
|
+
lines,
|
|
34
|
+
...overrides,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe(shareSession, () => {
|
|
39
|
+
test("publishes the named session and reports the copied ssh line", async () => {
|
|
40
|
+
// Arrange
|
|
41
|
+
const publish = mock(async (session: ReviewSession) => (expect(session.id).toBe("ses_2"), { line: "ssh p_abc123xy@cueloop.dev", copied: true }));
|
|
42
|
+
const deps = depsSpy({ publish });
|
|
43
|
+
|
|
44
|
+
// Act
|
|
45
|
+
const code = await shareSession(fakeClient([sessionFixture("ses_1"), sessionFixture("ses_2")]), { sessionId: "ses_2" }, deps);
|
|
46
|
+
|
|
47
|
+
// Assert
|
|
48
|
+
expect(code).toBe(0);
|
|
49
|
+
expect(deps.lines).toEqual(["share link copied - ssh p_abc123xy@cueloop.dev"]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("without an id, shares the most recent session", async () => {
|
|
53
|
+
// Arrange
|
|
54
|
+
const publish = mock(async (session: ReviewSession) => (expect(session.id).toBe("ses_2"), { line: "ssh p_zzzzzzzz@cueloop.dev", copied: true }));
|
|
55
|
+
const deps = depsSpy({ publish });
|
|
56
|
+
|
|
57
|
+
// Act
|
|
58
|
+
await shareSession(fakeClient([sessionFixture("ses_1"), sessionFixture("ses_2")]), {}, deps);
|
|
59
|
+
|
|
60
|
+
// Assert
|
|
61
|
+
expect(deps.publish).toHaveBeenCalledTimes(1);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("prints the bare line when the clipboard is unavailable", async () => {
|
|
65
|
+
// Arrange
|
|
66
|
+
const deps = depsSpy({ publish: mock(async () => ({ line: "ssh p_abc123xy@cueloop.dev", copied: false })) });
|
|
67
|
+
|
|
68
|
+
// Act
|
|
69
|
+
await shareSession(fakeClient([sessionFixture("ses_1")]), { sessionId: "ses_1" }, deps);
|
|
70
|
+
|
|
71
|
+
// Assert
|
|
72
|
+
expect(deps.lines).toEqual(["ssh p_abc123xy@cueloop.dev"]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("reports nothing to share when the inbox is empty", async () => {
|
|
76
|
+
// Arrange
|
|
77
|
+
const deps = depsSpy();
|
|
78
|
+
|
|
79
|
+
// Act
|
|
80
|
+
const code = await shareSession(fakeClient([]), {}, deps);
|
|
81
|
+
|
|
82
|
+
// Assert
|
|
83
|
+
expect(code).toBe(1);
|
|
84
|
+
expect(deps.lines[0]).toContain("no plan to share");
|
|
85
|
+
expect(deps.publish).not.toHaveBeenCalled();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cueloop share [session-id]` - hand a plan to a teammate as one SSH line.
|
|
3
|
+
* This side just picks the session and delegates to the shared `publishShare`
|
|
4
|
+
* transport (the same one the in-TUI Share button uses); the gateway seals and
|
|
5
|
+
* stores. The transport and output are injected so the orchestration is
|
|
6
|
+
* testable without a live gateway.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { DaemonClient, type SessionClient } from "@cueloop/daemon/client";
|
|
10
|
+
import { publishShare, type ShareResult, type ShareTarget } from "@cueloop/client";
|
|
11
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
12
|
+
|
|
13
|
+
export interface ShareParams {
|
|
14
|
+
sessionId?: string;
|
|
15
|
+
host?: string;
|
|
16
|
+
port?: number;
|
|
17
|
+
home?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ShareDeps {
|
|
21
|
+
publish: (session: ReviewSession, target: ShareTarget) => Promise<ShareResult>;
|
|
22
|
+
out: (message: string) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const defaultDeps: ShareDeps = { publish: publishShare, out: (message) => console.log(message) };
|
|
26
|
+
|
|
27
|
+
/** Connect to the local daemon, then share the chosen session. */
|
|
28
|
+
export async function shareCommand(params: ShareParams, deps: ShareDeps = defaultDeps): Promise<number> {
|
|
29
|
+
const client = await DaemonClient.connect({ home: params.home, autostart: true });
|
|
30
|
+
try {
|
|
31
|
+
return await shareSession(client, params, deps);
|
|
32
|
+
} finally {
|
|
33
|
+
client.close();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The orchestration, over any SessionClient - the seam the tests drive. */
|
|
38
|
+
export async function shareSession(client: SessionClient, params: ShareParams, deps: ShareDeps): Promise<number> {
|
|
39
|
+
const session = await pickSession(client, params.sessionId);
|
|
40
|
+
if (!session) {
|
|
41
|
+
deps.out("no plan to share - open a review first");
|
|
42
|
+
return 1;
|
|
43
|
+
}
|
|
44
|
+
const { line, copied } = await deps.publish(session, { host: params.host, port: params.port });
|
|
45
|
+
deps.out(copied ? `share link copied - ${line}` : line);
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The named session, or the most recent one when no id is given. */
|
|
50
|
+
async function pickSession(client: SessionClient, sessionId?: string): Promise<ReviewSession | null> {
|
|
51
|
+
if (sessionId) return client.sessionGet(sessionId);
|
|
52
|
+
const sessions = await client.sessionList();
|
|
53
|
+
return sessions.at(-1) ?? null;
|
|
54
|
+
}
|