cueloop 0.1.0-alpha.72 → 0.1.0-alpha.74
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 +5 -5
- package/src/daemon-control.test.ts +43 -0
- package/src/daemon-control.ts +108 -0
- package/src/main.ts +30 -35
- package/src/pr.ts +2 -2
- package/src/share-command.test.ts +37 -0
- package/src/share-command.ts +6 -1
- package/src/update-command.test.ts +44 -4
- package/src/update-command.ts +19 -4
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.74",
|
|
4
4
|
"description": "Terminal review surface for coding agents: annotate plans and diffs, return a verdict, send structured feedback back",
|
|
5
5
|
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -30,10 +30,10 @@
|
|
|
30
30
|
"build:binary": "bun build --compile --external cpu-features ./src/main.ts --outfile dist/cueloop"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@cueloop/adapters": "0.1.0-alpha.
|
|
34
|
-
"@cueloop/client": "0.1.0-alpha.
|
|
35
|
-
"@cueloop/daemon": "0.1.0-alpha.
|
|
36
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
33
|
+
"@cueloop/adapters": "0.1.0-alpha.74",
|
|
34
|
+
"@cueloop/client": "0.1.0-alpha.74",
|
|
35
|
+
"@cueloop/daemon": "0.1.0-alpha.74",
|
|
36
|
+
"@cueloop/schema": "0.1.0-alpha.74",
|
|
37
37
|
"valibot": "^1.4.2"
|
|
38
38
|
},
|
|
39
39
|
"engines": {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cueloop stop`'s primitive against a real detached daemon: stopping it clears
|
|
3
|
+
* the socket and pid so the next launch autostarts a fresh one (the self-heal
|
|
4
|
+
* `cueloop update` relies on), and stopping when none runs is a no-op.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { afterEach, beforeEach, expect, test } from "bun:test";
|
|
8
|
+
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { DaemonClient } from "@cueloop/daemon/client";
|
|
12
|
+
import { pidPath, socketPath } from "@cueloop/daemon/paths";
|
|
13
|
+
import { stopDaemon } from "./daemon-control";
|
|
14
|
+
|
|
15
|
+
let home: string;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-stop-"));
|
|
19
|
+
});
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
await stopDaemon(home).catch(() => undefined);
|
|
22
|
+
rmSync(home, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("stopDaemon stops a running daemon and clears its socket and pid", async () => {
|
|
26
|
+
// Arrange - a real detached daemon owns the home
|
|
27
|
+
const client = await DaemonClient.connect({ home, autostart: true });
|
|
28
|
+
|
|
29
|
+
client.close();
|
|
30
|
+
expect(existsSync(socketPath(home))).toBe(true);
|
|
31
|
+
|
|
32
|
+
// Act
|
|
33
|
+
const stopped = await stopDaemon(home);
|
|
34
|
+
|
|
35
|
+
// Assert - it reported a stop, and the daemon released its socket and pid
|
|
36
|
+
expect(stopped).toBe(true);
|
|
37
|
+
expect(existsSync(socketPath(home))).toBe(false);
|
|
38
|
+
expect(existsSync(pidPath(home))).toBe(false);
|
|
39
|
+
}, 60_000);
|
|
40
|
+
|
|
41
|
+
test("stopDaemon reports nothing to stop when no daemon is running", async () => {
|
|
42
|
+
expect(await stopDaemon(home)).toBe(false);
|
|
43
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local daemon control: `cueloop stop` and `cueloop restart`, and the
|
|
3
|
+
* `stopDaemon` primitive `cueloop update` calls so a newer binary is not left
|
|
4
|
+
* talking to the old-version daemon (which refuses the handshake).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
8
|
+
import * as v from "valibot";
|
|
9
|
+
import { DaemonClient } from "@cueloop/daemon/client";
|
|
10
|
+
import { cueloopHome, pidPath, socketPath } from "@cueloop/daemon/paths";
|
|
11
|
+
|
|
12
|
+
const ProcessIdSchema = v.pipe(
|
|
13
|
+
v.string(),
|
|
14
|
+
v.transform((raw) => Number(raw.trim())),
|
|
15
|
+
v.integer(),
|
|
16
|
+
v.minValue(1),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
/** Signal the daemon through its pid file; callers only reach this while a socket proves it is live. */
|
|
20
|
+
function signalDaemonByProcessId(home: string): boolean {
|
|
21
|
+
const path = pidPath(home);
|
|
22
|
+
|
|
23
|
+
if (!existsSync(path)) return false;
|
|
24
|
+
let raw: string;
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
raw = readFileSync(path, "utf8");
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
const parsed = v.safeParse(ProcessIdSchema, raw);
|
|
32
|
+
|
|
33
|
+
if (!parsed.success) return false;
|
|
34
|
+
try {
|
|
35
|
+
process.kill(parsed.output);
|
|
36
|
+
|
|
37
|
+
return true;
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** True once the stopped daemon has released its socket; false if it is still there at the deadline. */
|
|
44
|
+
async function waitForSocketGone(home: string): Promise<boolean> {
|
|
45
|
+
const path = socketPath(home);
|
|
46
|
+
const deadline = Date.now() + 5_000;
|
|
47
|
+
|
|
48
|
+
while (Date.now() < deadline) {
|
|
49
|
+
if (!existsSync(path)) return true;
|
|
50
|
+
await Bun.sleep(50);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return !existsSync(path);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Stop the daemon that owns `home`, returning true once its socket is released.
|
|
58
|
+
* A live daemon is stopped by the owner-gated shutdown request, or, when it
|
|
59
|
+
* refuses the handshake (a version mismatch), by signalling its pid - safe
|
|
60
|
+
* because the socket proves the pid file is current. A home with no socket has
|
|
61
|
+
* no live daemon to stop, so its pid file is never trusted.
|
|
62
|
+
*/
|
|
63
|
+
export async function stopDaemon(home = cueloopHome()): Promise<boolean> {
|
|
64
|
+
if (!existsSync(socketPath(home))) return false;
|
|
65
|
+
try {
|
|
66
|
+
const client = await DaemonClient.connect({ home, autostart: false });
|
|
67
|
+
|
|
68
|
+
await client.shutdown();
|
|
69
|
+
client.close();
|
|
70
|
+
} catch {
|
|
71
|
+
if (!signalDaemonByProcessId(home)) return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return waitForSocketGone(home);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `cueloop stop` - stop the local daemon. */
|
|
78
|
+
export async function stopCommand(): Promise<number> {
|
|
79
|
+
const home = cueloopHome();
|
|
80
|
+
|
|
81
|
+
if (!existsSync(socketPath(home))) {
|
|
82
|
+
console.log("no cueloop daemon is running");
|
|
83
|
+
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
const stopped = await stopDaemon(home);
|
|
87
|
+
|
|
88
|
+
console.log(stopped ? "cueloop daemon stopped" : "cueloop daemon did not stop in time");
|
|
89
|
+
|
|
90
|
+
return stopped ? 0 : 1;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** `cueloop restart` - stop the local daemon, then autostart a fresh one. */
|
|
94
|
+
export async function restartCommand(): Promise<number> {
|
|
95
|
+
const home = cueloopHome();
|
|
96
|
+
|
|
97
|
+
await stopDaemon(home);
|
|
98
|
+
// a wedged daemon that never released its socket would otherwise be reused, not replaced
|
|
99
|
+
const socket = socketPath(home);
|
|
100
|
+
|
|
101
|
+
if (existsSync(socket)) rmSync(socket, { force: true });
|
|
102
|
+
const client = await DaemonClient.connect({ home, autostart: true });
|
|
103
|
+
|
|
104
|
+
client.close();
|
|
105
|
+
console.log("cueloop daemon restarted");
|
|
106
|
+
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
package/src/main.ts
CHANGED
|
@@ -23,9 +23,8 @@ import {
|
|
|
23
23
|
import { sessionCommand } from "./session-commands";
|
|
24
24
|
import { CLI_VERSION } from "./version";
|
|
25
25
|
import { DaemonClient } from "@cueloop/daemon/client";
|
|
26
|
-
import { workingTreeDiff } from "@cueloop/daemon/working-tree";
|
|
27
26
|
import type { Thread } from "@cueloop/schema";
|
|
28
|
-
import { openReview
|
|
27
|
+
import { openReview } from "@cueloop/daemon/review";
|
|
29
28
|
|
|
30
29
|
const argv = process.argv.slice(2);
|
|
31
30
|
const cmd = argv[0];
|
|
@@ -105,6 +104,8 @@ interface CommandHandlers {
|
|
|
105
104
|
const commandHandlers: CommandHandlers = {
|
|
106
105
|
session: (rest) => sessionCommand(rest),
|
|
107
106
|
daemon: (rest) => daemonCommand(rest),
|
|
107
|
+
stop: async () => (await import("./daemon-control")).stopCommand(),
|
|
108
|
+
restart: async () => (await import("./daemon-control")).restartCommand(),
|
|
108
109
|
plan: (rest) => planCommand(rest),
|
|
109
110
|
reply: (rest) => replyCommand(rest),
|
|
110
111
|
diff: (rest) => diffCommand(rest),
|
|
@@ -165,6 +166,7 @@ async function openReviewOfKind(
|
|
|
165
166
|
match: (session: Thread) => boolean,
|
|
166
167
|
label: string,
|
|
167
168
|
selector: string | undefined,
|
|
169
|
+
layout: "review" | "plan" | undefined,
|
|
168
170
|
emptyMessage?: string,
|
|
169
171
|
): Promise<number> {
|
|
170
172
|
const client = await DaemonClient.connect({ autostart: true });
|
|
@@ -177,7 +179,7 @@ async function openReviewOfKind(
|
|
|
177
179
|
}
|
|
178
180
|
const target = resolveOpenTarget(sessions, { match, selector });
|
|
179
181
|
|
|
180
|
-
if (target.kind === "session") return runTui(target.sessionId);
|
|
182
|
+
if (target.kind === "session") return runTui(target.sessionId, layout);
|
|
181
183
|
if (target.kind === "no-pending" && emptyMessage !== undefined) {
|
|
182
184
|
console.error(emptyMessage);
|
|
183
185
|
|
|
@@ -190,7 +192,7 @@ async function openReviewOfKind(
|
|
|
190
192
|
|
|
191
193
|
/** `cueloop plan [id|title]` - open the latest pending plan, or address one. */
|
|
192
194
|
async function planCommand(argv: string[]): Promise<number> {
|
|
193
|
-
return openReviewOfKind(isPlanReview, "plan", openSelector(parseArgs(argv)));
|
|
195
|
+
return openReviewOfKind(isPlanReview, "plan", openSelector(parseArgs(argv)), "plan");
|
|
194
196
|
}
|
|
195
197
|
|
|
196
198
|
/**
|
|
@@ -199,7 +201,7 @@ async function planCommand(argv: string[]): Promise<number> {
|
|
|
199
201
|
* by the /cueloop:reply skill; the opener is scope-only, like plan.
|
|
200
202
|
*/
|
|
201
203
|
async function replyCommand(argv: string[]): Promise<number> {
|
|
202
|
-
return openReviewOfKind(isReplyReview, "reply", openSelector(parseArgs(argv)));
|
|
204
|
+
return openReviewOfKind(isReplyReview, "reply", openSelector(parseArgs(argv)), "plan");
|
|
203
205
|
}
|
|
204
206
|
|
|
205
207
|
/**
|
|
@@ -219,7 +221,7 @@ async function prototypeCommand(argv: string[]): Promise<number> {
|
|
|
219
221
|
selector !== undefined && !isSessionId(selector) && (isHtmlPrototype || isMarkdownPrototype);
|
|
220
222
|
|
|
221
223
|
if (wantsOpen || !looksLikeFile)
|
|
222
|
-
return openReviewOfKind(isPrototypeReview, "prototype", selector);
|
|
224
|
+
return openReviewOfKind(isPrototypeReview, "prototype", selector, "plan");
|
|
223
225
|
|
|
224
226
|
const path = resolve(selector);
|
|
225
227
|
const content = await Bun.file(path)
|
|
@@ -242,47 +244,30 @@ async function prototypeCommand(argv: string[]): Promise<number> {
|
|
|
242
244
|
|
|
243
245
|
client.close();
|
|
244
246
|
|
|
245
|
-
return runTui(review.id);
|
|
247
|
+
return runTui(review.id, "plan");
|
|
246
248
|
}
|
|
247
249
|
|
|
248
250
|
/**
|
|
249
|
-
* `cueloop diff`
|
|
251
|
+
* `cueloop diff` opens the repo's workbench in the review layout:
|
|
250
252
|
* - a selector (`cueloop diff <id|title>`) or an explicit `--open`/`--latest`
|
|
251
|
-
* opens a pending diff
|
|
252
|
-
* -
|
|
253
|
-
*
|
|
254
|
-
*
|
|
253
|
+
* opens a specific pending diff thread;
|
|
254
|
+
* - a bare `cueloop diff` find-or-creates the per-repo workbench thread - a live-diff
|
|
255
|
+
* annotation container - so it is immediately annotatable and always shows the
|
|
256
|
+
* current working tree, reusing the same thread across re-runs.
|
|
255
257
|
*/
|
|
256
258
|
async function diffCommand(argv: string[]): Promise<number> {
|
|
257
259
|
const parsed = parseArgs(argv);
|
|
258
260
|
const selector = openSelector(parsed);
|
|
259
261
|
const wantsOpen = selector !== undefined || "open" in parsed.flags || "latest" in parsed.flags;
|
|
260
262
|
|
|
261
|
-
if (wantsOpen) return openReviewOfKind(isDiffReview, "diff", selector);
|
|
263
|
+
if (wantsOpen) return openReviewOfKind(isDiffReview, "diff", selector, "review");
|
|
262
264
|
|
|
263
|
-
const workspace = await resolveWorkspace();
|
|
264
|
-
const diff = await workingTreeDiff();
|
|
265
|
-
|
|
266
|
-
if (!diff.patch.trim()) {
|
|
267
|
-
return openReviewOfKind(
|
|
268
|
-
isDiffReview,
|
|
269
|
-
"diff",
|
|
270
|
-
undefined,
|
|
271
|
-
"working tree is clean and no pending diff review - nothing to open",
|
|
272
|
-
);
|
|
273
|
-
}
|
|
274
265
|
const client = await DaemonClient.connect({ autostart: true });
|
|
275
|
-
const
|
|
276
|
-
type: "diff",
|
|
277
|
-
content: diff.patch,
|
|
278
|
-
files: diff.files,
|
|
279
|
-
workspace,
|
|
280
|
-
title: `working tree @ ${workspace.branch}`,
|
|
281
|
-
});
|
|
266
|
+
const workbench = await client.sessionWorkbench(process.cwd());
|
|
282
267
|
|
|
283
268
|
client.close();
|
|
284
269
|
|
|
285
|
-
return runTui(
|
|
270
|
+
return runTui(workbench.id, "review");
|
|
286
271
|
}
|
|
287
272
|
|
|
288
273
|
/**
|
|
@@ -308,13 +293,21 @@ async function reviewEntry(argv: string[]): Promise<number> {
|
|
|
308
293
|
isPrReview,
|
|
309
294
|
"PR",
|
|
310
295
|
explicitOpen || looksLikeSessionId ? selector : undefined,
|
|
296
|
+
"review",
|
|
311
297
|
);
|
|
312
298
|
}
|
|
313
299
|
|
|
314
|
-
|
|
315
|
-
|
|
300
|
+
/**
|
|
301
|
+
* A create-command dictates the pane composition it opens in; a bare launch (`layout`
|
|
302
|
+
* omitted) restores the remembered one. Resolve the factory here so the heavy client
|
|
303
|
+
* module stays lazily imported for non-TUI commands.
|
|
304
|
+
*/
|
|
305
|
+
async function runTui(sessionId?: string, layout?: "review" | "plan"): Promise<number> {
|
|
306
|
+
const { runClient, reviewLayout, planLayout } = await import("@cueloop/client");
|
|
307
|
+
const resolved =
|
|
308
|
+
layout === "review" ? reviewLayout() : layout === "plan" ? planLayout() : undefined;
|
|
316
309
|
|
|
317
|
-
return runClient({ sessionId });
|
|
310
|
+
return runClient({ sessionId, layout: resolved });
|
|
318
311
|
}
|
|
319
312
|
|
|
320
313
|
function printHelp(): void {
|
|
@@ -354,6 +347,8 @@ function printHelp(): void {
|
|
|
354
347
|
" cueloop wake <id> [--harness codex --thread <id>] resume the agent with the verdict (spawn detached)",
|
|
355
348
|
" cueloop review-post <id> <pr> post a resolved session's verdict back to the PR",
|
|
356
349
|
" cueloop daemon run the daemon in the foreground",
|
|
350
|
+
" cueloop stop stop the local daemon",
|
|
351
|
+
" cueloop restart stop the local daemon and start a fresh one",
|
|
357
352
|
" cueloop dev open the TUI on an isolated home seeded with example threads",
|
|
358
353
|
"",
|
|
359
354
|
" cueloop -v, --version print the installed version",
|
package/src/pr.ts
CHANGED
|
@@ -80,9 +80,9 @@ export async function reviewCommand(argv: string[]): Promise<number> {
|
|
|
80
80
|
return 0;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
const { runClient } = await import("@cueloop/client");
|
|
83
|
+
const { runClient, reviewLayout } = await import("@cueloop/client");
|
|
84
84
|
|
|
85
|
-
await runClient({ sessionId: session.id });
|
|
85
|
+
await runClient({ sessionId: session.id, layout: reviewLayout() });
|
|
86
86
|
|
|
87
87
|
const after = await getSession(session.id);
|
|
88
88
|
|
|
@@ -133,6 +133,43 @@ describe(shareSession, () => {
|
|
|
133
133
|
expect(deps.lines).toEqual(["share link copied - ssh p_abc123xy@cueloop.dev"]);
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
+
test("freezes a workbench thread's live diff into the shared artifact", async () => {
|
|
137
|
+
// Arrange - a workbench thread (renders live locally) plus a daemon that reports a fresh diff
|
|
138
|
+
const workbench = sessionFixture("ses_wb", {
|
|
139
|
+
artifact: {
|
|
140
|
+
type: "diff",
|
|
141
|
+
content: "STALE",
|
|
142
|
+
files: [],
|
|
143
|
+
meta: { workbench: true, title: "Workbench" },
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
const client = {
|
|
147
|
+
...fakeClient([workbench]),
|
|
148
|
+
repoDiff: async () => ({
|
|
149
|
+
patch: "FRESH",
|
|
150
|
+
files: [{ path: "x.ts", oldContents: "", newContents: "y\n", status: "added" as const }],
|
|
151
|
+
}),
|
|
152
|
+
};
|
|
153
|
+
let published: Thread | undefined;
|
|
154
|
+
const deps = depsSpy({
|
|
155
|
+
publish: mock(async (session: Thread) => {
|
|
156
|
+
published = session;
|
|
157
|
+
|
|
158
|
+
return { line: "ssh p_abc123xy@cueloop.dev", copied: true };
|
|
159
|
+
}),
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Act
|
|
163
|
+
const code = await shareSession(client, { sessionId: "ses_wb" }, deps);
|
|
164
|
+
|
|
165
|
+
// Assert - the remote gets the fresh snapshot, marked so it renders frozen while keeping its notes
|
|
166
|
+
expect(code).toBe(0);
|
|
167
|
+
expect(published?.artifact.content).toBe("FRESH");
|
|
168
|
+
expect(published?.artifact.files).toHaveLength(1);
|
|
169
|
+
expect(published?.artifact.meta.snapshot).toBe(true);
|
|
170
|
+
expect(published?.artifact.meta.workbench).toBe(true);
|
|
171
|
+
});
|
|
172
|
+
|
|
136
173
|
test("without an id, shares the most recent session", async () => {
|
|
137
174
|
// Arrange
|
|
138
175
|
const publish = mock(
|
package/src/share-command.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
publishShare,
|
|
13
13
|
pullShare,
|
|
14
14
|
shareIdFromLine,
|
|
15
|
+
snapshotWorkbench,
|
|
15
16
|
type ShareResult,
|
|
16
17
|
type ShareTarget,
|
|
17
18
|
} from "@cueloop/client";
|
|
@@ -81,7 +82,11 @@ export async function shareSession(
|
|
|
81
82
|
return 1;
|
|
82
83
|
}
|
|
83
84
|
const session = params.fork ? await client.sessionFork(picked.id) : picked;
|
|
84
|
-
|
|
85
|
+
// a workbench thread renders live locally; freeze its diff so the remote reviewer gets a stable snapshot
|
|
86
|
+
const shared = client.repoDiff
|
|
87
|
+
? await snapshotWorkbench(session, client.repoDiff.bind(client))
|
|
88
|
+
: session;
|
|
89
|
+
const { line, copied } = await deps.publish(shared, { host: params.host, port: params.port });
|
|
85
90
|
const shareId = shareIdFromLine(line);
|
|
86
91
|
|
|
87
92
|
if (shareId) await client.sessionSetShareId(session.id, shareId);
|
|
@@ -3,16 +3,22 @@
|
|
|
3
3
|
import { describe, expect, mock, test } from "bun:test";
|
|
4
4
|
import { resolveInstallDir, runUpdate, type UpdateDeps } from "./update-command";
|
|
5
5
|
|
|
6
|
-
function depsSpy(
|
|
6
|
+
function depsSpy(
|
|
7
|
+
overrides: Partial<UpdateDeps> = {},
|
|
8
|
+
): UpdateDeps & { lines: string[]; errorLines: string[] } {
|
|
7
9
|
const lines: string[] = [];
|
|
10
|
+
const errorLines: string[] = [];
|
|
8
11
|
|
|
9
12
|
return {
|
|
10
13
|
currentVersion: "0.1.0-alpha.68",
|
|
11
14
|
installDir: () => "/Users/dev/.local/bin",
|
|
12
15
|
fetchLatestVersion: mock(async () => "0.1.0-alpha.68"),
|
|
13
16
|
runInstaller: mock(async () => 0),
|
|
17
|
+
stopDaemon: mock(async () => true),
|
|
14
18
|
out: (message: string) => void lines.push(message),
|
|
19
|
+
error: (message: string) => void errorLines.push(message),
|
|
15
20
|
lines,
|
|
21
|
+
errorLines,
|
|
16
22
|
...overrides,
|
|
17
23
|
};
|
|
18
24
|
}
|
|
@@ -116,9 +122,27 @@ describe(runUpdate, () => {
|
|
|
116
122
|
// Act
|
|
117
123
|
const code = await runUpdate(deps, false);
|
|
118
124
|
|
|
119
|
-
// Assert
|
|
125
|
+
// Assert - it also stops the stale-version daemon so the next launch autostarts the new build
|
|
120
126
|
expect(code).toBe(0);
|
|
121
127
|
expect(deps.runInstaller).toHaveBeenCalledWith("/Users/dev/.local/bin");
|
|
128
|
+
expect(deps.stopDaemon).toHaveBeenCalledTimes(1);
|
|
129
|
+
expect(deps.lines).toContain("Update ran successfully! Please restart cueloop.");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("a failing daemon stop never fails an install that already succeeded", async () => {
|
|
133
|
+
// Arrange - the install succeeds but the cleanup stop rejects
|
|
134
|
+
const deps = depsSpy({
|
|
135
|
+
fetchLatestVersion: mock(async () => "0.1.0-alpha.99"),
|
|
136
|
+
stopDaemon: mock(async () => {
|
|
137
|
+
throw new Error("pid file vanished");
|
|
138
|
+
}),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Act
|
|
142
|
+
const code = await runUpdate(deps, false);
|
|
143
|
+
|
|
144
|
+
// Assert - the update still reports success
|
|
145
|
+
expect(code).toBe(0);
|
|
122
146
|
expect(deps.lines).toContain("Update ran successfully! Please restart cueloop.");
|
|
123
147
|
});
|
|
124
148
|
|
|
@@ -143,8 +167,9 @@ describe(runUpdate, () => {
|
|
|
143
167
|
// Act
|
|
144
168
|
const code = await runUpdate(deps, false);
|
|
145
169
|
|
|
146
|
-
// Assert
|
|
170
|
+
// Assert - a failed install leaves the current daemon alone
|
|
147
171
|
expect(code).toBe(1);
|
|
172
|
+
expect(deps.stopDaemon).not.toHaveBeenCalled();
|
|
148
173
|
expect(deps.lines).not.toContain("Update ran successfully! Please restart cueloop.");
|
|
149
174
|
});
|
|
150
175
|
|
|
@@ -155,9 +180,24 @@ describe(runUpdate, () => {
|
|
|
155
180
|
// Act
|
|
156
181
|
const code = await runUpdate(deps, false);
|
|
157
182
|
|
|
183
|
+
// Assert - the failure goes to stderr, and no progress prints to stdout
|
|
184
|
+
expect(code).toBe(1);
|
|
185
|
+
expect(deps.errorLines[0]).toContain("must be an absolute path");
|
|
186
|
+
expect(deps.lines).toHaveLength(0);
|
|
187
|
+
expect(deps.runInstaller).not.toHaveBeenCalled();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("reports an unresolvable install dir on stderr with no stdout progress", async () => {
|
|
191
|
+
// Arrange - neither an override, the binary path, nor HOME resolves a target
|
|
192
|
+
const deps = depsSpy({ installDir: () => undefined });
|
|
193
|
+
|
|
194
|
+
// Act
|
|
195
|
+
const code = await runUpdate(deps, false);
|
|
196
|
+
|
|
158
197
|
// Assert
|
|
159
198
|
expect(code).toBe(1);
|
|
160
|
-
expect(deps.
|
|
199
|
+
expect(deps.errorLines[0]).toContain("HOME is not set");
|
|
200
|
+
expect(deps.lines).toHaveLength(0);
|
|
161
201
|
expect(deps.runInstaller).not.toHaveBeenCalled();
|
|
162
202
|
});
|
|
163
203
|
});
|
package/src/update-command.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { basename, dirname, isAbsolute, resolve } from "node:path";
|
|
2
2
|
import * as v from "valibot";
|
|
3
3
|
import { CLI_VERSION } from "./version";
|
|
4
|
+
import { stopDaemon } from "./daemon-control";
|
|
4
5
|
|
|
5
6
|
const INSTALL_URL = "https://cueloop.dev/install.sh";
|
|
6
7
|
const RELEASES_URL = "https://api.github.com/repos/mmurakaru/cueloop/releases?per_page=100";
|
|
@@ -117,7 +118,12 @@ export interface UpdateDeps {
|
|
|
117
118
|
installDir: () => string | undefined;
|
|
118
119
|
fetchLatestVersion: () => Promise<string | undefined>;
|
|
119
120
|
runInstaller: (targetInstallDir: string) => Promise<number>;
|
|
121
|
+
/** Stop the running daemon after an update so the next launch autostarts the new build; resolves true when one was stopped. */
|
|
122
|
+
stopDaemon: () => Promise<boolean>;
|
|
123
|
+
/** Progress and status, on stdout - not an error, so it must not read as one. */
|
|
120
124
|
out: (message: string) => void;
|
|
125
|
+
/** A failure that ends the run, on stderr. */
|
|
126
|
+
error: (message: string) => void;
|
|
121
127
|
}
|
|
122
128
|
|
|
123
129
|
/**
|
|
@@ -130,12 +136,12 @@ export async function runUpdate(deps: UpdateDeps, dryRun: boolean): Promise<numb
|
|
|
130
136
|
const targetInstallDir = deps.installDir();
|
|
131
137
|
|
|
132
138
|
if (targetInstallDir === undefined) {
|
|
133
|
-
deps.
|
|
139
|
+
deps.error("cueloop update: HOME is not set and the current binary path is unknown");
|
|
134
140
|
|
|
135
141
|
return 1;
|
|
136
142
|
}
|
|
137
143
|
if (!targetInstallDir.startsWith("/")) {
|
|
138
|
-
deps.
|
|
144
|
+
deps.error("cueloop update: CUELOOP_INSTALL_DIR must be an absolute path");
|
|
139
145
|
|
|
140
146
|
return 1;
|
|
141
147
|
}
|
|
@@ -157,7 +163,14 @@ export async function runUpdate(deps: UpdateDeps, dryRun: boolean): Promise<numb
|
|
|
157
163
|
deps.out(`updating cueloop in ${targetInstallDir}...`);
|
|
158
164
|
const exitCode = await deps.runInstaller(targetInstallDir);
|
|
159
165
|
|
|
160
|
-
if (exitCode === 0)
|
|
166
|
+
if (exitCode === 0) {
|
|
167
|
+
try {
|
|
168
|
+
await deps.stopDaemon();
|
|
169
|
+
} catch {
|
|
170
|
+
// stopping the old daemon is cleanup; it must never fail an install that already succeeded
|
|
171
|
+
}
|
|
172
|
+
deps.out("Update ran successfully! Please restart cueloop.");
|
|
173
|
+
}
|
|
161
174
|
|
|
162
175
|
return exitCode;
|
|
163
176
|
}
|
|
@@ -170,7 +183,9 @@ export async function updateCommand(argv: string[] = []): Promise<number> {
|
|
|
170
183
|
installDir: () => resolveInstallDir(process.execPath, process.env),
|
|
171
184
|
fetchLatestVersion,
|
|
172
185
|
runInstaller,
|
|
173
|
-
|
|
186
|
+
stopDaemon: () => stopDaemon(),
|
|
187
|
+
out: (message) => console.log(message),
|
|
188
|
+
error: (message) => console.error(message),
|
|
174
189
|
},
|
|
175
190
|
argv.includes("--dry-run"),
|
|
176
191
|
);
|